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 +20 -0
- doppelhand/agent.py +174 -0
- doppelhand/cli.py +461 -0
- doppelhand/duplication.py +387 -0
- doppelhand/errors.py +31 -0
- doppelhand/executor.py +299 -0
- doppelhand/inputs.py +350 -0
- doppelhand/screen.py +381 -0
- doppelhand/serve.py +215 -0
- doppelhand/session.py +62 -0
- doppelhand/skill/SKILL.md +123 -0
- doppelhand/skills.py +47 -0
- doppelhand-1.3.1.dist-info/METADATA +191 -0
- doppelhand-1.3.1.dist-info/RECORD +18 -0
- doppelhand-1.3.1.dist-info/WHEEL +5 -0
- doppelhand-1.3.1.dist-info/entry_points.txt +2 -0
- doppelhand-1.3.1.dist-info/licenses/LICENSE +21 -0
- doppelhand-1.3.1.dist-info/top_level.txt +1 -0
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())
|