doppelhand 1.3.1__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.
doppelhand/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """doppelhand — read the Windows screen, drive the mouse and keyboard."""
2
+
3
+ from doppelhand.errors import (
4
+ ActionError,
5
+ Aborted,
6
+ DoppelhandError,
7
+ Refused,
8
+ StepLimit,
9
+ )
10
+
11
+ __version__ = "1.3.1"
12
+
13
+ __all__ = [
14
+ "ActionError",
15
+ "Aborted",
16
+ "DoppelhandError",
17
+ "Refused",
18
+ "StepLimit",
19
+ "__version__",
20
+ ]
doppelhand/agent.py ADDED
@@ -0,0 +1,174 @@
1
+ """The perceive-decide-act loop: Claude looks at the screen and doppelhand acts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from doppelhand.errors import ActionError, Aborted, Refused, StepLimit
8
+ from doppelhand.executor import Executor
9
+
10
+ DEFAULT_MODEL = "claude-opus-5"
11
+ TOOLSET_NAME = "computer"
12
+ COMPUTER_TOOLSET = {"type": "computer_toolset_20260801"}
13
+ HALT_TEXT = "Not executed: an earlier computer action in this turn failed."
14
+
15
+ SYSTEM_PROMPT = (
16
+ "You are operating a Windows desktop through screenshots and synthetic mouse and "
17
+ "keyboard input. Screen coordinates are the pixels of the screenshots you receive: "
18
+ "{width} wide by {height} high, origin at the top left.\n"
19
+ "Take a screenshot before your first action and after any action that changes what "
20
+ "is on screen, and read the result before deciding the next step. Prefer keyboard "
21
+ "shortcuts where they are more reliable than clicking.\n"
22
+ "Do only what the task asks. If the task is finished, say so and stop calling tools. "
23
+ "If you are stuck, cannot see what you need, or the screen is not what you expected, "
24
+ "stop and explain what you see instead of guessing."
25
+ )
26
+
27
+
28
+ class Agent:
29
+ """Runs one task to completion, or until the step budget is spent."""
30
+
31
+ def __init__(
32
+ self,
33
+ client=None,
34
+ model: str = DEFAULT_MODEL,
35
+ executor: Executor | None = None,
36
+ max_steps: int = 30,
37
+ max_tokens: int = 16000,
38
+ max_images: int = 10,
39
+ prune_batch: int = 5,
40
+ on_event: Callable[[str, str], None] | None = None,
41
+ ):
42
+ if client is None:
43
+ import anthropic
44
+
45
+ client = anthropic.Anthropic()
46
+ self.client = client
47
+ self.model = model
48
+ self.executor = executor or Executor()
49
+ self.max_steps = max_steps
50
+ self.max_tokens = max_tokens
51
+ self.max_images = max_images
52
+ self.prune_batch = prune_batch
53
+ self.on_event = on_event or (lambda kind, detail: None)
54
+ self.messages: list[dict] = []
55
+
56
+ def run(self, task: str) -> str:
57
+ width, height = self.executor.view_size
58
+ self.messages = [{"role": "user", "content": task}]
59
+
60
+ for step in range(1, self.max_steps + 1):
61
+ self.on_event("step", f"{step}/{self.max_steps}")
62
+ response = self.client.messages.create(
63
+ model=self.model,
64
+ max_tokens=self.max_tokens,
65
+ system=SYSTEM_PROMPT.format(width=width, height=height),
66
+ tools=[COMPUTER_TOOLSET],
67
+ messages=self.messages,
68
+ )
69
+ if response.stop_reason == "refusal":
70
+ details = getattr(response, "stop_details", None)
71
+ raise Refused(getattr(details, "category", None),
72
+ getattr(details, "explanation", None))
73
+
74
+ self.messages.append({"role": "assistant", "content": response.content})
75
+ for block in response.content:
76
+ if getattr(block, "type", None) == "text" and block.text.strip():
77
+ self.on_event("say", block.text.strip())
78
+
79
+ calls = [b for b in response.content if getattr(b, "type", None) == "tool_use"]
80
+ if not calls:
81
+ return _final_text(response)
82
+
83
+ results = self._run_batch(calls)
84
+ _prune_screenshots(self.messages, self.max_images, self.prune_batch)
85
+ _move_cache_breakpoint(self.messages, results)
86
+ self.messages.append({"role": "user", "content": results})
87
+
88
+ raise StepLimit(f"stopped after {self.max_steps} steps without finishing")
89
+
90
+ def _run_batch(self, calls: list) -> list[dict]:
91
+ """Run the turn's actions in order. After a failure the rest are refused rather
92
+ than run, because each one assumed the screen the failed action would have left."""
93
+ results = []
94
+ halted = False
95
+ for call in calls:
96
+ if halted:
97
+ results.append(_error_result(call.id, HALT_TEXT))
98
+ continue
99
+ if getattr(call, "toolset_name", TOOLSET_NAME) != TOOLSET_NAME:
100
+ results.append(_error_result(call.id, f"unknown tool: {call.name}"))
101
+ halted = True
102
+ continue
103
+ self.on_event("act", f"{call.name} {dict(call.input) if call.input else ''}".strip())
104
+ try:
105
+ content = self.executor.dispatch(call.name, dict(call.input or {}))
106
+ except Aborted:
107
+ raise
108
+ except ActionError as exc:
109
+ results.append(_error_result(call.id, str(exc)))
110
+ halted = True
111
+ else:
112
+ results.append({
113
+ "type": "tool_result",
114
+ "tool_use_id": call.id,
115
+ "toolset_name": TOOLSET_NAME,
116
+ "content": content,
117
+ })
118
+ return results
119
+
120
+
121
+ def _final_text(response) -> str:
122
+ return "\n".join(b.text for b in response.content
123
+ if getattr(b, "type", None) == "text").strip()
124
+
125
+
126
+ def _error_result(tool_use_id: str, message: str) -> dict:
127
+ return {
128
+ "type": "tool_result",
129
+ "tool_use_id": tool_use_id,
130
+ "toolset_name": TOOLSET_NAME,
131
+ "is_error": True,
132
+ "content": message,
133
+ }
134
+
135
+
136
+ def _move_cache_breakpoint(messages: list[dict], results: list[dict]) -> None:
137
+ """Keep one cache breakpoint, on the newest tool result. Everything before it is a
138
+ stable prefix that the next request can read from cache instead of resending."""
139
+ for message in messages:
140
+ for block in message.get("content", []) if isinstance(message.get("content"), list) else []:
141
+ if isinstance(block, dict):
142
+ block.pop("cache_control", None)
143
+ if results:
144
+ results[-1]["cache_control"] = {"type": "ephemeral"}
145
+
146
+
147
+ def _prune_screenshots(messages: list[dict], keep: int, batch: int) -> int:
148
+ """Drop all but the newest `keep` screenshots once `batch` extra ones have piled up.
149
+
150
+ Pruning rewrites history and so costs a cache read, which is why it waits for a
151
+ batch instead of trimming one image per turn.
152
+
153
+ Returns the number of screenshots removed.
154
+ """
155
+ blocks = [block for message in messages
156
+ for block in _image_blocks(message)]
157
+ if len(blocks) <= keep + batch:
158
+ return 0
159
+ removed = 0
160
+ for block in blocks[:len(blocks) - keep]:
161
+ block["content"] = [{"type": "text", "text": "[earlier screenshot removed]"}]
162
+ removed += 1
163
+ return removed
164
+
165
+
166
+ def _image_blocks(message: dict) -> list[dict]:
167
+ content = message.get("content")
168
+ if not isinstance(content, list):
169
+ return []
170
+ return [block for block in content
171
+ if isinstance(block, dict)
172
+ and block.get("type") == "tool_result"
173
+ and any(isinstance(part, dict) and part.get("type") == "image"
174
+ for part in block.get("content", []))]
doppelhand/cli.py ADDED
@@ -0,0 +1,461 @@
1
+ """Command line entry point.
2
+
3
+ Two audiences share it. The action commands print one JSON object each and are meant to
4
+ be driven by another agent, which supplies the judgement between them. `run` is the
5
+ standalone path, where doppelhand asks Claude what to do next itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from doppelhand import __version__, session, skills
16
+ from doppelhand.errors import Aborted, DoppelhandError, Refused, StepLimit, UsageError
17
+ from doppelhand.executor import DEFAULT_MAX_EDGE, Executor, fit
18
+
19
+ SPACES = ("view", "display")
20
+
21
+
22
+ def _use_utf8() -> None:
23
+ """Model output and window titles routinely contain characters the default Windows
24
+ console encoding cannot represent, and printing one would end the run."""
25
+ for stream in (sys.stdout, sys.stderr):
26
+ if stream and hasattr(stream, "reconfigure"):
27
+ stream.reconfigure(encoding="utf-8", errors="replace")
28
+
29
+
30
+ def coordinate(text: str) -> tuple[int, int]:
31
+ try:
32
+ x, y = (part.strip() for part in text.split(","))
33
+ return int(x), int(y)
34
+ except ValueError:
35
+ raise argparse.ArgumentTypeError(f"expected X,Y but got {text!r}") from None
36
+
37
+
38
+ class JsonParser(argparse.ArgumentParser):
39
+ """Raises usage problems instead of printing them, so the same parser can answer a
40
+ command line and an HTTP request without either one inheriting the other's output."""
41
+
42
+ def error(self, message: str):
43
+ raise UsageError(message, self.format_usage().strip())
44
+
45
+
46
+ def monitor_choice(text: str) -> int | str:
47
+ wanted = text.strip().lower()
48
+ if wanted in ("all", "primary"):
49
+ return wanted
50
+ try:
51
+ number = int(wanted)
52
+ except ValueError:
53
+ raise argparse.ArgumentTypeError(
54
+ f"expected a monitor number, 'primary' or 'all', got {text!r}") from None
55
+ if number < 1:
56
+ raise argparse.ArgumentTypeError("monitors are numbered from 1")
57
+ return number
58
+
59
+
60
+ def region(text: str) -> tuple[int, int, int, int]:
61
+ try:
62
+ left, top, width, height = (int(part.strip()) for part in text.split(","))
63
+ except ValueError:
64
+ raise argparse.ArgumentTypeError(f"expected X,Y,W,H but got {text!r}") from None
65
+ if width <= 0 or height <= 0:
66
+ raise argparse.ArgumentTypeError(f"region has no area: {width}x{height}")
67
+ return left, top, width, height
68
+
69
+
70
+ def build_parser() -> argparse.ArgumentParser:
71
+ parser = JsonParser(
72
+ prog="doppelhand",
73
+ description="Read the Windows screen, drive the mouse and keyboard.",
74
+ )
75
+ parser.add_argument("--version", action="version", version=f"doppelhand {__version__}")
76
+ commands = parser.add_subparsers(dest="command", required=True)
77
+
78
+ def action(name: str, help_text: str) -> argparse.ArgumentParser:
79
+ sub = commands.add_parser(name, help=help_text)
80
+ sub.add_argument("--space", choices=SPACES, default="view",
81
+ help="coordinate space of the arguments (default: view)")
82
+ sub.add_argument("--max-edge", type=int, default=None,
83
+ help="long edge of the view space (default: the last shot's)")
84
+ sub.add_argument("--monitor", type=monitor_choice, default=None,
85
+ help="which display: a number, 'primary' or 'all'")
86
+ return sub
87
+
88
+ shot = action("shot", "capture the screen to a PNG")
89
+ shot.add_argument("out", nargs="?", default=None, help="where to write the PNG")
90
+ shot.add_argument("--region", type=region, default=None,
91
+ help="capture X,Y,W,H instead of the whole display")
92
+ shot.add_argument("--no-cursor", dest="cursor", action="store_false",
93
+ help="leave the mouse pointer out of the capture")
94
+ shot.add_argument("--fast", action="store_true",
95
+ help="JPEG and a cheaper resize: about a third of the time, "
96
+ "slightly softer text")
97
+
98
+ action("screen", "report the coordinate space without capturing")
99
+ action("cursor", "report where the pointer is")
100
+
101
+ click = action("click", "click at a point")
102
+ click.add_argument("at", type=coordinate)
103
+ click.add_argument("--button", choices=("left", "right", "middle"), default="left")
104
+ click.add_argument("--count", type=int, default=1, help="2 double clicks, 3 selects a line")
105
+ click.add_argument("--modifiers", default=None, help="keys to hold, e.g. ctrl+shift")
106
+
107
+ move = action("move", "move the pointer without clicking")
108
+ move.add_argument("at", type=coordinate)
109
+
110
+ drag = action("drag", "press, drag and release")
111
+ drag.add_argument("start", type=coordinate)
112
+ drag.add_argument("end", type=coordinate)
113
+ drag.add_argument("--modifiers", default=None)
114
+
115
+ scroll = action("scroll", "scroll the surface under the pointer")
116
+ scroll.add_argument("direction", choices=("up", "down", "left", "right"))
117
+ scroll.add_argument("amount", nargs="?", type=int, default=3)
118
+ scroll.add_argument("--at", type=coordinate, default=None)
119
+ scroll.add_argument("--modifiers", default=None)
120
+
121
+ type_text = action("type", "type literal text at the keyboard focus")
122
+ type_text.add_argument("text")
123
+
124
+ key = action("key", "press a key or combination, e.g. ctrl+s")
125
+ key.add_argument("combo")
126
+ key.add_argument("--repeat", type=int, default=1)
127
+
128
+ hold = action("hold", "hold a key down")
129
+ hold.add_argument("combo")
130
+ hold.add_argument("seconds", type=float)
131
+
132
+ wait = action("wait", "pause and let the screen settle")
133
+ wait.add_argument("seconds", type=float)
134
+
135
+ run = commands.add_parser("run", help="carry out a whole task with Claude driving")
136
+ run.add_argument("task", help="what to do, in plain language")
137
+ run.add_argument("--model", default=None, help="Claude model to drive the run")
138
+ run.add_argument("--max-steps", type=int, default=30, help="model turns before giving up")
139
+ run.add_argument("--max-edge", type=int, default=DEFAULT_MAX_EDGE,
140
+ help="long edge of the screenshots sent to the model")
141
+ run.add_argument("--monitor", type=monitor_choice, default=None,
142
+ help="which display: a number, 'primary' or 'all'")
143
+ run.add_argument("-y", "--yes", action="store_true", help="skip the confirmation")
144
+ run.add_argument("-q", "--quiet", action="store_true", help="only print the final answer")
145
+
146
+ serve_cmd = commands.add_parser(
147
+ "serve", help="hold a warm process open and answer commands over HTTP")
148
+ serve_cmd.add_argument("--port", type=int, default=0,
149
+ help="port on 127.0.0.1, or 0 to be given a free one")
150
+ serve_cmd.add_argument("--token", default=None,
151
+ help="shared secret callers must send, generated if omitted")
152
+ serve_cmd.add_argument("-q", "--quiet", action="store_true")
153
+
154
+ install = commands.add_parser("install-skill",
155
+ help="install the agent skill into a harness")
156
+ install.add_argument("target", nargs="?", choices=sorted(skills.TARGETS), default=None)
157
+ install.add_argument("--dest", default=None, help="install into this directory instead")
158
+ install.add_argument("--print", dest="show", action="store_true",
159
+ help="write the skill to stdout instead of installing it")
160
+ install.add_argument("--force", action="store_true", help="replace an existing skill")
161
+ return parser
162
+
163
+
164
+ _cached_parser: argparse.ArgumentParser | None = None
165
+
166
+
167
+ def cached_parser() -> argparse.ArgumentParser:
168
+ """Building the parser costs more than most actions do, and parsing does not change
169
+ it, so a long-lived process builds it once."""
170
+ global _cached_parser
171
+ if _cached_parser is None:
172
+ _cached_parser = build_parser()
173
+ return _cached_parser
174
+
175
+
176
+ def _executor(args) -> Executor:
177
+ """Build the executor whose view space the arguments are written in.
178
+
179
+ `--space display` is the same thing with the scale pinned to 1, so there is one
180
+ coordinate path rather than two.
181
+ """
182
+ from doppelhand import screen
183
+
184
+ attached = screen.monitors()
185
+ noted_edge, noted_monitor = session.recall(session.layout_of(attached))
186
+ wanted = getattr(args, "monitor", None)
187
+ if wanted is None:
188
+ wanted = noted_monitor
189
+ monitor = _pick_monitor(attached, wanted)
190
+
191
+ if getattr(args, "space", "view") == "display":
192
+ max_edge = max(monitor.size)
193
+ else:
194
+ max_edge = args.max_edge or noted_edge or DEFAULT_MAX_EDGE
195
+ return Executor(max_edge=max_edge, monitor=monitor,
196
+ cursor=getattr(args, "cursor", True))
197
+
198
+
199
+ def _pick_monitor(attached: list, wanted):
200
+ from doppelhand import screen
201
+
202
+ if wanted in ("all", 0):
203
+ return screen.virtual_monitor()
204
+ if wanted in (None, "primary"):
205
+ return next((m for m in attached if m.primary), attached[0])
206
+ for monitor in attached:
207
+ if monitor.index == wanted:
208
+ return monitor
209
+ raise DoppelhandError(f"there is no monitor {wanted}; "
210
+ f"attached: {[m.index for m in attached]}")
211
+
212
+
213
+ def _space(executor: Executor, args) -> dict:
214
+ return {
215
+ "monitor": executor.monitor.index,
216
+ "view": list(executor.view_size),
217
+ "display": list(executor.screen_size),
218
+ "scale": round(executor.scale, 6),
219
+ "space": getattr(args, "space", "view"),
220
+ }
221
+
222
+
223
+ def cmd_shot(args, executor: Executor) -> dict:
224
+ from doppelhand import screen
225
+
226
+ fast = getattr(args, "fast", False)
227
+ default_name = "shot.jpg" if fast else "shot.png"
228
+ out = Path(args.out or (session.state_dir() / default_name))
229
+ out.parent.mkdir(parents=True, exist_ok=True)
230
+
231
+ if args.region:
232
+ image = fit(executor.frame(executor.crop_box(*args.region)),
233
+ executor.max_edge, fast=fast)
234
+ else:
235
+ image = executor.capture(fast=fast)
236
+ if fast:
237
+ image.save(out, format="JPEG", quality=85)
238
+ else:
239
+ image.save(out)
240
+
241
+ payload = {"path": str(out), **_space(executor, args)}
242
+ if args.region:
243
+ # A crop has its own origin, so points read off it are not screen points.
244
+ payload["region"] = list(args.region)
245
+ payload["space"] = "region"
246
+ payload["note"] = "read this image, do not take click coordinates from it"
247
+ else:
248
+ # The PNG is already on disk, so a failure to note the space cannot make this
249
+ # a failed capture. It costs the caller a flag on the next command.
250
+ payload["space_remembered"] = session.remember(
251
+ executor.max_edge, executor.monitor.index,
252
+ session.layout_of(screen.monitors()))
253
+ payload["image"] = list(image.size)
254
+ payload["cursor_drawn"] = executor.cursor
255
+ payload["source"] = executor.last_source
256
+ return payload
257
+
258
+
259
+ def cmd_screen(args, executor: Executor) -> dict:
260
+ from doppelhand import screen
261
+
262
+ return {"monitors": [m.describe() for m in screen.monitors()],
263
+ **_space(executor, args)}
264
+
265
+
266
+ def cmd_cursor(args, executor: Executor) -> dict:
267
+ from doppelhand import inputs, screen
268
+
269
+ x, y = inputs.cursor_position()
270
+ on = next((m.index for m in screen.monitors()
271
+ if m.origin[0] <= x < m.origin[0] + m.size[0]
272
+ and m.origin[1] <= y < m.origin[1] + m.size[1]), None)
273
+ return {"view": list(executor.to_view(x, y)), "display": [x, y],
274
+ "monitor": executor.monitor.index, "pointer_on_monitor": on,
275
+ "scale": round(executor.scale, 6)}
276
+
277
+
278
+ def cmd_click(args, executor: Executor) -> dict:
279
+ if args.count == 1:
280
+ member = f"{args.button}_click"
281
+ elif args.count in (2, 3):
282
+ if args.button != "left":
283
+ raise DoppelhandError("only the left button has a double or triple click")
284
+ member = "double_click" if args.count == 2 else "triple_click"
285
+ else:
286
+ raise DoppelhandError(f"count must be 1, 2 or 3, got {args.count}")
287
+ executor.dispatch(member, {"coordinate": list(args.at), "text": args.modifiers})
288
+ return {"action": member, "at": list(args.at)}
289
+
290
+
291
+ def cmd_move(args, executor: Executor) -> dict:
292
+ executor.dispatch("mouse_move", {"coordinate": list(args.at)})
293
+ return {"action": "mouse_move", "at": list(args.at)}
294
+
295
+
296
+ def cmd_drag(args, executor: Executor) -> dict:
297
+ executor.dispatch("left_click_drag", {"start_coordinate": list(args.start),
298
+ "coordinate": list(args.end),
299
+ "text": args.modifiers})
300
+ return {"action": "left_click_drag", "from": list(args.start), "to": list(args.end)}
301
+
302
+
303
+ def cmd_scroll(args, executor: Executor) -> dict:
304
+ if args.amount < 1:
305
+ # A negative amount reverses the wheel, which would contradict the direction
306
+ # this command reports back.
307
+ raise DoppelhandError(f"amount must be 1 or more, got {args.amount}")
308
+ params = {"scroll_direction": args.direction, "scroll_amount": args.amount,
309
+ "text": args.modifiers}
310
+ if args.at:
311
+ params["coordinate"] = list(args.at)
312
+ executor.dispatch("scroll", params)
313
+ return {"action": "scroll", "direction": args.direction, "amount": args.amount}
314
+
315
+
316
+ def cmd_type(args, executor: Executor) -> dict:
317
+ executor.dispatch("type", {"text": args.text})
318
+ return {"action": "type", "characters": len(args.text)}
319
+
320
+
321
+ def cmd_key(args, executor: Executor) -> dict:
322
+ repeat = min(100, max(1, args.repeat)) # the toolset's own ceiling
323
+ executor.dispatch("key", {"text": args.combo, "repeat": repeat})
324
+ return {"action": "key", "combo": args.combo, "repeat": repeat}
325
+
326
+
327
+ def cmd_hold(args, executor: Executor) -> dict:
328
+ executor.dispatch("hold_key", {"text": args.combo, "duration": args.seconds})
329
+ return {"action": "hold_key", "combo": args.combo, "seconds": args.seconds}
330
+
331
+
332
+ def cmd_wait(args, executor: Executor) -> dict:
333
+ executor.dispatch("wait", {"duration": args.seconds})
334
+ return {"action": "wait", "seconds": args.seconds}
335
+
336
+
337
+ ACTIONS = {
338
+ "shot": cmd_shot, "screen": cmd_screen, "cursor": cmd_cursor, "click": cmd_click,
339
+ "move": cmd_move, "drag": cmd_drag, "scroll": cmd_scroll, "type": cmd_type,
340
+ "key": cmd_key, "hold": cmd_hold, "wait": cmd_wait,
341
+ }
342
+
343
+
344
+ def cmd_install_skill(args) -> int:
345
+ if args.show:
346
+ print(skills.skill_text())
347
+ return 0
348
+ if not args.target and not args.dest:
349
+ print(json.dumps({"ok": False, "error": "name a harness or pass --dest",
350
+ "known": sorted(skills.TARGETS)}))
351
+ return 1
352
+ path = skills.install(args.target, args.dest, args.force)
353
+ print(json.dumps({"ok": True, "installed": str(path), "harness": args.target}))
354
+ return 0
355
+
356
+
357
+ def cmd_run(args) -> int:
358
+ try:
359
+ import anthropic
360
+ except ImportError:
361
+ print("doppelhand: this command needs the Anthropic SDK. "
362
+ "Install it with: pip install doppelhand[run]", file=sys.stderr)
363
+ return 1
364
+
365
+ from doppelhand import screen
366
+ from doppelhand.agent import DEFAULT_MODEL, Agent
367
+
368
+ model = args.model or DEFAULT_MODEL
369
+
370
+ def report(kind: str, detail: str) -> None:
371
+ if args.quiet:
372
+ return
373
+ marks = {"step": "--", "act": "->", "say": " "}
374
+ print(f"{marks.get(kind, ' ')} {detail}", flush=True)
375
+
376
+ try:
377
+ # Picking the display can fail on a bad --monitor, so it belongs with the rest
378
+ # of the run's error handling rather than above it.
379
+ executor = Executor(max_edge=args.max_edge,
380
+ monitor=_pick_monitor(screen.monitors(), args.monitor))
381
+ if not args.yes and not _confirm(executor, model, args):
382
+ print("cancelled")
383
+ return 1
384
+ agent = Agent(model=model, executor=executor, max_steps=args.max_steps,
385
+ on_event=report)
386
+ answer = agent.run(args.task)
387
+ except anthropic.AnthropicError as exc:
388
+ print(f"doppelhand: {exc}", file=sys.stderr)
389
+ return 1
390
+ except TypeError as exc:
391
+ # The SDK reports missing credentials as a TypeError when it builds the request.
392
+ if "authentication" not in str(exc).lower():
393
+ raise
394
+ print("doppelhand: no Anthropic credentials found. Set ANTHROPIC_API_KEY.",
395
+ file=sys.stderr)
396
+ return 1
397
+ except Aborted as exc:
398
+ print(f"doppelhand: stopped, {exc}", file=sys.stderr)
399
+ return 130
400
+ except StepLimit as exc:
401
+ print(f"doppelhand: {exc}", file=sys.stderr)
402
+ return 2
403
+ except Refused as exc:
404
+ print(f"doppelhand: {exc}", file=sys.stderr)
405
+ return 3
406
+ except DoppelhandError as exc:
407
+ print(f"doppelhand: {exc}", file=sys.stderr)
408
+ return 1
409
+
410
+ if answer:
411
+ print(answer)
412
+ return 0
413
+
414
+
415
+ def _confirm(executor: Executor, model: str, args) -> bool:
416
+ width, height = executor.view_size
417
+ print(f"doppelhand {__version__}")
418
+ print(f" task {args.task}")
419
+ print(f" model {model}")
420
+ print(f" screen monitor {executor.monitor.index}, "
421
+ f"{executor.screen_size[0]}x{executor.screen_size[1]} "
422
+ f"-> {width}x{height} sent to the model")
423
+ print(f" budget {args.max_steps} steps")
424
+ print("This takes over the mouse and keyboard of this machine. "
425
+ "Hold Escape at any point to stop it.")
426
+ try:
427
+ return input("Start? [y/N] ").strip().lower() in {"y", "yes"}
428
+ except EOFError:
429
+ return False
430
+
431
+
432
+ def main(argv: list[str] | None = None) -> int:
433
+ _use_utf8()
434
+ try:
435
+ args = build_parser().parse_args(argv)
436
+ except UsageError as exc:
437
+ print(json.dumps({"ok": False, "error": str(exc), "usage": exc.usage}))
438
+ return 2
439
+
440
+ if args.command == "run":
441
+ return cmd_run(args)
442
+ if args.command == "serve":
443
+ from doppelhand.serve import serve
444
+
445
+ return serve(port=args.port, token=args.token, quiet=args.quiet)
446
+ try:
447
+ if args.command == "install-skill":
448
+ return cmd_install_skill(args)
449
+ payload = ACTIONS[args.command](args, _executor(args))
450
+ except DoppelhandError as exc:
451
+ print(json.dumps({"ok": False, "error": str(exc)}))
452
+ return 1
453
+ except OSError as exc:
454
+ print(json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}))
455
+ return 1
456
+ print(json.dumps({"ok": True, **payload}))
457
+ return 0
458
+
459
+
460
+ if __name__ == "__main__": # pragma: no cover
461
+ raise SystemExit(main())