add backend-aware computer-use and inline capture support
- extend `_a0_connector` computer-use metadata handling to retain `backend_id`, `backend_family`, `features`, and `support_reason` from the CLI hello payload - update `computer_use_remote` to prefer inline `png_base64` screenshots for capture and auto-refresh flows, while keeping filesystem-path fallback for migration/debug cases - include backend information in status formatting so remote computer-use sessions are easier to inspect across Wayland and Windows backends - align the builtin Agent Zero plugin with the new multi-backend computer-use transport used by `a0` 1.5 - replaced heavy CU instructions with a SKILL.md
Alessandro committed
Apr 17, 2026 at 02:10 UTC
bdf9cad447bc171fabe3fb378982346b51c09884
8 files changed
+755
-127
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -21,6 +21,7 @@ _BASE_FEATURES = [
21
"projects",
22
"text_editor_remote",
23
"code_execution_remote",
24
+ "computer_use_remote",
25
"remote_file_tree",
26
"token_status",
27
]
plugins/_a0_connector/api/ws_connector.py
+42
-2
@@ -8,16 +8,20 @@ from helpers.print_style import PrintStyle
8
from helpers.ws import WsHandler
9
from helpers.ws_manager import WsResult
10
11
-from plugins._a0_connector.helpers.event_bridge import get_context_log_entries
11
from plugins._a0_connector.helpers.exec_config import build_exec_config
12
+from plugins._a0_connector.helpers.event_bridge import get_context_log_entries
13
from plugins._a0_connector.helpers.ws_runtime import (
14
clear_remote_tree_snapshot,
15
+ clear_sid_computer_use_metadata,
16
+ fail_pending_computer_use_ops_for_sid,
17
fail_pending_file_ops_for_sid,
18
fail_pending_exec_ops_for_sid,
19
register_sid,
18
- resolve_pending_file_op,
20
+ resolve_pending_computer_use_op,
21
resolve_pending_exec_op,
22
+ resolve_pending_file_op,
23
store_remote_tree_snapshot,
24
+ store_sid_computer_use_metadata,
25
subscribe_sid_to_context,
26
subscribed_contexts_for_sid,
27
subscribed_sids_for_context,
@@ -36,6 +40,7 @@ WS_FEATURES = [
40
"text_editor_remote",
41
"remote_file_tree",
42
"code_execution_remote",
43
+ "computer_use_remote",
44
]
45
46
@@ -71,6 +76,11 @@ class WsConnector(WsHandler):
76
sid,
77
error="CLI disconnected before completing the requested remote execution",
78
)
79
+ fail_pending_computer_use_ops_for_sid(
80
+ sid,
81
+ error="CLI disconnected before completing the requested computer-use operation",
82
+ )
83
+ clear_sid_computer_use_metadata(sid)
84
PrintStyle.debug(f"[a0-connector] /ws disconnected: {sid}")
85
86
async def process(
@@ -80,6 +90,11 @@ class WsConnector(WsHandler):
90
sid: str,
91
) -> dict[str, Any] | WsResult | None:
92
if event == "connector_hello":
93
+ computer_use = data.get("computer_use")
94
+ if isinstance(computer_use, dict):
95
+ store_sid_computer_use_metadata(sid, computer_use)
96
+ else:
97
+ clear_sid_computer_use_metadata(sid)
98
return {
99
"protocol": PROTOCOL_VERSION,
100
"features": WS_FEATURES,
@@ -104,6 +119,9 @@ class WsConnector(WsHandler):
119
if event == "connector_exec_op_result":
120
return self._handle_exec_op_result(data, sid)
121
122
+ if event == "connector_computer_use_op_result":
123
+ return self._handle_computer_use_op_result(data, sid)
124
+
125
if event.startswith("connector_"):
126
return WsResult.error(
127
code="UNKNOWN_EVENT",
@@ -347,6 +365,28 @@ class WsConnector(WsHandler):
365
366
return {"op_id": op_id, "accepted": True}
367
368
+ def _handle_computer_use_op_result(
369
+ self,
370
+ data: dict[str, Any],
371
+ sid: str,
372
+ ) -> dict[str, Any] | WsResult:
373
+ op_id = str(data.get("op_id", "")).strip()
374
+ if not op_id:
375
+ return WsResult.error(
376
+ code="MISSING_OP_ID",
377
+ message="op_id is required",
378
+ correlation_id=data.get("correlationId"),
379
+ )
380
+
381
+ if not resolve_pending_computer_use_op(op_id, sid=sid, payload=data):
382
+ return WsResult.error(
383
+ code="UNKNOWN_OP_ID",
384
+ message=f"No pending computer-use operation for op_id '{op_id}'",
385
+ correlation_id=data.get("correlationId"),
386
+ )
387
+
388
+ return {"op_id": op_id, "accepted": True}
389
+
390
async def _resolve_context(
391
self,
392
*,
plugins/_a0_connector/helpers/exec_config.py
+20
-80
@@ -2,95 +2,35 @@ from __future__ import annotations
2
3
from typing import Any
4
5
-_TIMEOUT_KEYS = (
6
- "first_output_timeout",
7
- "between_output_timeout",
8
- "max_exec_timeout",
9
- "dialog_timeout",
10
-)
5
12
-_DEFAULT_CODE_EXEC_TIMEOUTS = {
13
- "first_output_timeout": 30,
14
- "between_output_timeout": 15,
15
- "max_exec_timeout": 180,
16
- "dialog_timeout": 5,
17
-}
18
-_DEFAULT_OUTPUT_TIMEOUTS = {
19
- "first_output_timeout": 90,
20
- "between_output_timeout": 45,
21
- "max_exec_timeout": 300,
22
- "dialog_timeout": 5,
23
-}
24
-_DEFAULT_PROMPT_PATTERNS = [
25
- r"(\(venv\)).+[$#] ?$",
26
- r"root@[^:]+:[^#]+# ?$",
27
- r"[a-zA-Z0-9_.-]+@[^:]+:[^$#]+[$#] ?$",
28
- r"\(?.*\)?\s*PS\s+[^>]+> ?$",
29
-]
30
-_DEFAULT_DIALOG_PATTERNS = [
31
- r"Y/N",
32
- r"yes/no",
33
- r":\s*$",
34
- r"\?\s*$",
35
-]
6
+_TIMEOUT_KEYS = ("first_output_timeout", "between_output_timeout", "max_exec_timeout", "dialog_timeout")
7
8
38
-def _coerce_timeout_group(raw: Any, defaults: dict[str, int]) -> dict[str, int]:
39
- group = raw if isinstance(raw, dict) else {}
40
- result: dict[str, int] = {}
41
- for key in _TIMEOUT_KEYS:
42
- value = group.get(key, defaults[key])
43
- try:
44
- result[key] = int(value)
45
- except (TypeError, ValueError):
46
- result[key] = defaults[key]
47
- return result
48
-
49
-
50
-def _pattern_lines(raw: Any, defaults: list[str]) -> list[str]:
51
- if isinstance(raw, list):
52
- values = raw
53
- elif isinstance(raw, str):
54
- values = raw.splitlines()
9
+def _parse_patterns(value: object) -> list[str]:
10
+ if isinstance(value, str):
11
+ items = value.splitlines()
12
+ elif isinstance(value, (list, tuple)):
13
+ items = value
14
else:
56
- values = defaults
15
+ return []
16
+ return [str(item).strip() for item in items if str(item).strip()]
17
+
18
58
- patterns = [str(value).strip() for value in values if str(value).strip()]
59
- return patterns or list(defaults)
19
+def _parse_timeouts(cfg: dict[str, Any], prefix: str, defaults: tuple[int, ...]) -> dict[str, int]:
20
+ return {
21
+ key: int(cfg.get(f"{prefix}_{key}", default))
22
+ for key, default in zip(_TIMEOUT_KEYS, defaults)
23
+ }
24
25
62
-def build_exec_config() -> dict[str, Any]:
26
+def build_exec_config(*, agent: object | None = None) -> dict[str, Any]:
27
from helpers import plugins
28
65
- try:
66
- config = plugins.get_plugin_config("_code_execution") or {}
67
- except Exception:
68
- config = {}
69
-
29
+ cfg = plugins.get_plugin_config("_code_execution", agent=agent) or {}
30
return {
31
"version": 1,
72
- "code_exec_timeouts": _coerce_timeout_group(
73
- config.get("code_exec_timeouts") or {
74
- key: config.get(f"code_exec_{key}")
75
- for key in _TIMEOUT_KEYS
76
- if f"code_exec_{key}" in config
77
- },
78
- _DEFAULT_CODE_EXEC_TIMEOUTS,
79
- ),
80
- "output_timeouts": _coerce_timeout_group(
81
- config.get("output_timeouts") or {
82
- key: config.get(f"output_{key}")
83
- for key in _TIMEOUT_KEYS
84
- if f"output_{key}" in config
85
- },
86
- _DEFAULT_OUTPUT_TIMEOUTS,
87
- ),
88
- "prompt_patterns": _pattern_lines(
89
- config.get("prompt_patterns"),
90
- _DEFAULT_PROMPT_PATTERNS,
91
- ),
92
- "dialog_patterns": _pattern_lines(
93
- config.get("dialog_patterns"),
94
- _DEFAULT_DIALOG_PATTERNS,
95
- ),
32
+ "code_exec_timeouts": _parse_timeouts(cfg, "code_exec", (30, 15, 180, 5)),
33
+ "output_timeouts": _parse_timeouts(cfg, "output", (90, 45, 300, 5)),
34
+ "prompt_patterns": _parse_patterns(cfg.get("prompt_patterns", "")),
35
+ "dialog_patterns": _parse_patterns(cfg.get("dialog_patterns", "")),
36
}
plugins/_a0_connector/helpers/ws_runtime.py
+174
-44
@@ -23,6 +23,14 @@ class PendingExecOperation:
23
context_id: str | None = None
24
25
26
+@dataclass
27
+class PendingComputerUseOperation:
28
+ sid: str
29
+ loop: asyncio.AbstractEventLoop
30
+ future: asyncio.Future[dict[str, Any]]
31
+ context_id: str | None = None
32
+
33
+
34
@dataclass(frozen=True)
35
class RemoteTreeSnapshot:
36
sid: str
@@ -30,11 +38,26 @@ class RemoteTreeSnapshot:
38
updated_at: float
39
40
41
+@dataclass(frozen=True)
42
+class ComputerUseMetadata:
43
+ supported: bool
44
+ enabled: bool
45
+ trust_mode: str
46
+ artifact_root: str
47
+ backend_id: str
48
+ backend_family: str
49
+ features: tuple[str, ...]
50
+ support_reason: str
51
+ updated_at: float
52
+
53
+
54
_context_subscriptions: dict[str, set[str]] = {}
55
_sid_contexts: dict[str, set[str]] = {}
56
_pending_file_ops: dict[str, PendingFileOperation] = {}
57
_pending_exec_ops: dict[str, PendingExecOperation] = {}
58
+_pending_computer_use_ops: dict[str, PendingComputerUseOperation] = {}
59
_remote_tree_snapshots: dict[str, RemoteTreeSnapshot] = {}
60
+_sid_computer_use_metadata: dict[str, ComputerUseMetadata] = {}
61
_state_lock = threading.RLock()
62
63
@@ -47,6 +70,7 @@ def unregister_sid(sid: str) -> set[str]:
70
with _state_lock:
71
contexts = _sid_contexts.pop(sid, set())
72
_remote_tree_snapshots.pop(sid, None)
73
+ _sid_computer_use_metadata.pop(sid, None)
74
for context_id in contexts:
75
subscribers = _context_subscriptions.get(context_id)
76
if not subscribers:
@@ -143,6 +167,61 @@ def select_target_sid(context_id: str) -> str | None:
167
return sorted(subscribers)[0]
168
169
170
+def store_sid_computer_use_metadata(sid: str, payload: dict[str, Any]) -> ComputerUseMetadata:
171
+ features_value = payload.get("features")
172
+ if isinstance(features_value, (list, tuple)):
173
+ features = tuple(str(item).strip() for item in features_value if str(item).strip())
174
+ else:
175
+ features = ()
176
+ metadata = ComputerUseMetadata(
177
+ supported=bool(payload.get("supported")),
178
+ enabled=bool(payload.get("supported")) and bool(payload.get("enabled")),
179
+ trust_mode=str(payload.get("trust_mode", "") or "").strip(),
180
+ artifact_root=str(payload.get("artifact_root", "") or "").strip(),
181
+ backend_id=str(payload.get("backend_id", "") or "").strip(),
182
+ backend_family=str(payload.get("backend_family", "") or "").strip(),
183
+ features=features,
184
+ support_reason=str(payload.get("support_reason", "") or "").strip(),
185
+ updated_at=time.time(),
186
+ )
187
+ with _state_lock:
188
+ _sid_computer_use_metadata[sid] = metadata
189
+ return metadata
190
+
191
+
192
+def clear_sid_computer_use_metadata(sid: str) -> None:
193
+ with _state_lock:
194
+ _sid_computer_use_metadata.pop(sid, None)
195
+
196
+
197
+def computer_use_metadata_for_sid(sid: str) -> dict[str, Any] | None:
198
+ with _state_lock:
199
+ metadata = _sid_computer_use_metadata.get(sid)
200
+ if metadata is None:
201
+ return None
202
+ return {
203
+ "supported": metadata.supported,
204
+ "enabled": metadata.enabled,
205
+ "trust_mode": metadata.trust_mode,
206
+ "artifact_root": metadata.artifact_root,
207
+ "backend_id": metadata.backend_id,
208
+ "backend_family": metadata.backend_family,
209
+ "features": list(metadata.features),
210
+ "support_reason": metadata.support_reason,
211
+ "updated_at": metadata.updated_at,
212
+ }
213
+
214
+
215
+def select_computer_use_target_sid(context_id: str) -> str | None:
216
+ with _state_lock:
217
+ subscribers = sorted(_context_subscriptions.get(context_id, set()))
218
+ for sid in subscribers:
219
+ metadata = _sid_computer_use_metadata.get(sid)
220
+ if metadata and metadata.supported and metadata.enabled:
221
+ return sid
222
+ return None
223
+
224
+
225
def store_pending_file_op(
226
op_id: str,
227
*,
@@ -171,14 +250,7 @@ def resolve_pending_file_op(
250
sid: str,
251
payload: dict[str, Any],
252
) -> bool:
174
- with _state_lock:
175
- pending = _pending_file_ops.get(op_id)
176
- if pending is None or pending.sid != sid:
177
- return False
178
- _pending_file_ops.pop(op_id, None)
179
-
180
- pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
181
- return True
253
+ return _resolve_pending(_pending_file_ops, op_id, sid=sid, payload=payload)
254
255
256
def fail_pending_file_op(
@@ -187,32 +259,11 @@ def fail_pending_file_op(
259
sid: str | None = None,
260
error: str,
261
) -> bool:
190
- with _state_lock:
191
- pending = _pending_file_ops.get(op_id)
192
- if pending is None:
193
- return False
194
- if sid is not None and pending.sid != sid:
195
- return False
196
- _pending_file_ops.pop(op_id, None)
197
-
198
- payload = {"op_id": op_id, "ok": False, "error": error}
199
- pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
200
- return True
262
+ return _fail_pending(_pending_file_ops, op_id, sid=sid, error=error)
263
264
265
def fail_pending_file_ops_for_sid(sid: str, *, error: str) -> None:
204
- with _state_lock:
205
- matches = [
206
- (op_id, pending)
207
- for op_id, pending in _pending_file_ops.items()
208
- if pending.sid == sid
209
- ]
210
- for op_id, _pending in matches:
211
- _pending_file_ops.pop(op_id, None)
212
-
213
- for op_id, pending in matches:
214
- payload = {"op_id": op_id, "ok": False, "error": error}
215
- pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
266
+ _fail_pending_for_sid(_pending_file_ops, sid=sid, error=error)
267
268
269
def store_pending_exec_op(
@@ -243,48 +294,127 @@ def resolve_pending_exec_op(
294
sid: str,
295
payload: dict[str, Any],
296
) -> bool:
297
+ return _resolve_pending(_pending_exec_ops, op_id, sid=sid, payload=payload)
298
+
299
+
300
+def fail_pending_exec_op(
301
+ op_id: str,
302
+ *,
303
+ sid: str | None = None,
304
+ error: str,
305
+) -> bool:
306
+ return _fail_pending(_pending_exec_ops, op_id, sid=sid, error=error)
307
+
308
+
309
+def fail_pending_exec_ops_for_sid(sid: str, *, error: str) -> None:
310
+ _fail_pending_for_sid(_pending_exec_ops, sid=sid, error=error)
311
+
312
+
313
+def store_pending_computer_use_op(
314
+ op_id: str,
315
+ *,
316
+ sid: str,
317
+ future: asyncio.Future[dict[str, Any]],
318
+ loop: asyncio.AbstractEventLoop,
319
+ context_id: str | None = None,
320
+) -> None:
321
+ with _state_lock:
322
+ _pending_computer_use_ops[op_id] = PendingComputerUseOperation(
323
+ sid=sid,
324
+ loop=loop,
325
+ future=future,
326
+ context_id=context_id,
327
+ )
328
+
329
+
330
+def clear_pending_computer_use_op(op_id: str) -> None:
331
with _state_lock:
247
- pending = _pending_exec_ops.get(op_id)
332
+ _pending_computer_use_ops.pop(op_id, None)
333
+
334
+
335
+def resolve_pending_computer_use_op(
336
+ op_id: str,
337
+ *,
338
+ sid: str,
339
+ payload: dict[str, Any],
340
+) -> bool:
341
+ return _resolve_pending(_pending_computer_use_ops, op_id, sid=sid, payload=payload)
342
+
343
+
344
+def fail_pending_computer_use_op(
345
+ op_id: str,
346
+ *,
347
+ sid: str | None = None,
348
+ error: str,
349
+) -> bool:
350
+ return _fail_pending(_pending_computer_use_ops, op_id, sid=sid, error=error)
351
+
352
+
353
+def fail_pending_computer_use_ops_for_sid(sid: str, *, error: str) -> None:
354
+ _fail_pending_for_sid(_pending_computer_use_ops, sid=sid, error=error)
355
+
356
+
357
+def _resolve_pending(
358
+ registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation],
359
+ op_id: str,
360
+ *,
361
+ sid: str,
362
+ payload: dict[str, Any],
363
+) -> bool:
364
+ with _state_lock:
365
+ pending = registry.get(op_id)
366
if pending is None or pending.sid != sid:
367
return False
250
- _pending_exec_ops.pop(op_id, None)
368
+ registry.pop(op_id, None)
369
370
pending.loop.call_soon_threadsafe(_set_future_result, pending.future, dict(payload))
371
return True
372
373
256
-def fail_pending_exec_op(
374
+def _fail_pending(
375
+ registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation],
376
op_id: str,
377
*,
259
- sid: str | None = None,
378
+ sid: str | None,
379
error: str,
380
) -> bool:
381
with _state_lock:
263
- pending = _pending_exec_ops.get(op_id)
382
+ pending = registry.get(op_id)
383
if pending is None:
384
return False
385
if sid is not None and pending.sid != sid:
386
return False
268
- _pending_exec_ops.pop(op_id, None)
387
+ registry.pop(op_id, None)
388
270
- payload = {"op_id": op_id, "ok": False, "error": error}
271
- pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
389
+ pending.loop.call_soon_threadsafe(
390
+ _set_future_result,
391
+ pending.future,
392
+ {"op_id": op_id, "ok": False, "error": error},
393
+ )
394
return True
395
396
275
-def fail_pending_exec_ops_for_sid(sid: str, *, error: str) -> None:
397
+def _fail_pending_for_sid(
398
+ registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation],
399
+ *,
400
+ sid: str,
401
+ error: str,
402
+) -> None:
403
with _state_lock:
404
matches = [
405
(op_id, pending)
279
- for op_id, pending in _pending_exec_ops.items()
406
+ for op_id, pending in registry.items()
407
if pending.sid == sid
408
]
409
for op_id, _pending in matches:
283
- _pending_exec_ops.pop(op_id, None)
410
+ registry.pop(op_id, None)
411
412
for op_id, pending in matches:
286
- payload = {"op_id": op_id, "ok": False, "error": error}
287
- pending.loop.call_soon_threadsafe(_set_future_result, pending.future, payload)
413
+ pending.loop.call_soon_threadsafe(
414
+ _set_future_result,
415
+ pending.future,
416
+ {"op_id": op_id, "ok": False, "error": error},
417
+ )
418
419
420
def _set_future_result(
plugins/_a0_connector/plugin.yaml
+1
-1
@@ -1,7 +1,7 @@
1
name: _a0_connector
2
title: A0 Connector
3
description: Current Agent Zero connector plugin for HTTP plus /ws integration, using session auth and handler activation through auth.handlers.
4
-version: 1.4
4
+version: 1.5.0
5
settings_sections:
6
- external
7
- developer
plugins/_a0_connector/prompts/agent.system.tool.computer_use_remote.md
new
+34
@@ -0,0 +1,34 @@
1
+# computer_use_remote tool
2
+
3
+Use the connected CLI host machine as a local desktop target.
4
+
5
+## Preferred Scope
6
+- Use this for local desktop and native UI tasks on the connected machine.
7
+- For ordinary website browsing, search, form filling, and web downloads, prefer `browser_agent`.
8
+- If the user is flexible and the task is browser-only, briefly guide them toward browser tools because they are usually more reliable and token-efficient than screenshot-driven computer use.
9
+- Before doing real computer-use work, load the `computer-use-remote` skill and follow it.
10
+
11
+## Requirements
12
+- A CLI client must be connected to this context via the shared `/ws` namespace.
13
+- The CLI must advertise `computer_use_remote` support and local computer use must be enabled there.
14
+- In `free_run`, do not expect a fresh approval prompt. If restore is no longer valid, the tool will surface `COMPUTER_USE_REARM_REQUIRED`.
15
+
16
+## Minimal Rules
17
+- Treat user interventions as high-priority control signals.
18
+- If the user says `stop`, `pause`, `abort`, `hold`, `don't continue`, or equivalent, halt immediately and do not use computer-use tools again until the user explicitly resumes.
19
+- Call `start_session` first. It automatically attaches the current screen.
20
+- Decide from the latest screenshot, not from memory.
21
+- Interactive actions (`move`, `click`, `scroll`, `key`, `type`) automatically attach a fresh screenshot after they run.
22
+- Use `capture` only when you need another screen refresh without taking an action.
23
+- Prefer keyboard actions over pointer actions whenever a reliable keyboard path exists.
24
+
25
+## Arguments
26
+- `action`: one of `start_session`, `status`, `capture`, `move`, `click`, `scroll`, `key`, `type`, `stop_session`
27
+- `session_id`: optional for actions after `start_session`
28
+
29
+Action-specific fields:
30
+- `move`: `x`, `y` normalized to `[0,1]`
31
+- `click`: optional `x`, `y`, plus optional `button` (`left`, `right`, `middle`) and `count`
32
+- `scroll`: `dx`, `dy`
33
+- `key`: `key` or `keys`
34
+- `type`: `text`, optional `submit` boolean
plugins/_a0_connector/tools/computer_use_remote.py
new
+433
@@ -0,0 +1,433 @@
1
+"""computer_use_remote tool — drive the CLI host machine through the connected frontend."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+import base64
6
+from pathlib import Path
7
+import uuid
8
+from typing import Any
9
+
10
+from helpers import history
11
+from helpers.tool import Response, Tool
12
+from helpers.ws import NAMESPACE
13
+from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
14
+
15
+from plugins._a0_connector.helpers.ws_runtime import (
16
+ clear_pending_computer_use_op,
17
+ select_computer_use_target_sid,
18
+ store_pending_computer_use_op,
19
+)
20
+
21
+
22
+COMPUTER_USE_OP_TIMEOUT = 180.0
23
+COMPUTER_USE_OP_EVENT = "connector_computer_use_op"
24
+CAPTURE_TOKENS_ESTIMATE = 1500
25
+_AUTO_CAPTURE_ACTIONS = {
26
+ "start_session",
27
+ "move",
28
+ "click",
29
+ "scroll",
30
+ "key",
31
+ "type",
32
+}
33
+_SETTLE_DELAY_START_SESSION = 0.2
34
+_SETTLE_DELAY_GLOBAL_FOCUS = 0.45
35
+_SETTLE_DELAY_PLAIN_ENTER = 0.3
36
+_SETTLE_DELAY_SUBMIT = 0.45
37
+_SUPPORTED_ACTIONS = {
38
+ "start_session",
39
+ "status",
40
+ "capture",
41
+ "move",
42
+ "click",
43
+ "scroll",
44
+ "key",
45
+ "type",
46
+ "stop_session",
47
+}
48
+
49
+
50
+class ComputerUseRemote(Tool):
51
+ async def execute(self, **kwargs: Any) -> Response:
52
+ action = str(self.args.get("action") or "").strip().lower()
53
+ if action not in _SUPPORTED_ACTIONS:
54
+ return Response(
55
+ message=(
56
+ "action is required and must be one of: "
57
+ "start_session, status, capture, move, click, scroll, key, type, stop_session"
58
+ ),
59
+ break_loop=False,
60
+ )
61
+
62
+ context_id = self.agent.context.id
63
+ sid = select_computer_use_target_sid(context_id)
64
+ if not sid:
65
+ return Response(
66
+ message=(
67
+ "computer_use_remote: no subscribed CLI in this context currently advertises "
68
+ "enabled local computer use. Enable it in the CLI with F2 and choose a trust mode first."
69
+ ),
70
+ break_loop=False,
71
+ )
72
+
73
+ try:
74
+ payload = self._build_payload(op_id=str(uuid.uuid4()), context_id=context_id, action=action)
75
+ result = await self._dispatch_payload(sid=sid, payload=payload)
76
+ capture_note = await self._maybe_attach_latest_capture(
77
+ action=action,
78
+ sid=sid,
79
+ context_id=context_id,
80
+ result=result,
81
+ )
82
+ except ValueError as exc:
83
+ return Response(
84
+ message=f"computer_use_remote: {exc}",
85
+ break_loop=False,
86
+ )
87
+ except ConnectionNotFoundError:
88
+ return Response(
89
+ message=(
90
+ "computer_use_remote: the selected CLI disconnected before the request "
91
+ "could be delivered."
92
+ ),
93
+ break_loop=False,
94
+ )
95
+ except asyncio.TimeoutError:
96
+ return Response(
97
+ message=f"computer_use_remote: timed out waiting for action={action!r}",
98
+ break_loop=False,
99
+ )
100
+ except Exception as exc:
101
+ return Response(
102
+ message=f"computer_use_remote: error sending action={action!r}: {exc}",
103
+ break_loop=False,
104
+ )
105
+
106
+ message = self._extract_result(action, result)
107
+ if capture_note:
108
+ message = f"{message} {capture_note}".strip()
109
+
110
+ return Response(
111
+ message=message,
112
+ break_loop=False,
113
+ )
114
+
115
+ async def _dispatch_payload(self, *, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
116
+ op_id = str(payload.get("op_id") or "").strip()
117
+ loop = asyncio.get_running_loop()
118
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
119
+ store_pending_computer_use_op(
120
+ op_id,
121
+ sid=sid,
122
+ future=future,
123
+ loop=loop,
124
+ context_id=str(payload.get("context_id") or "").strip() or None,
125
+ )
126
+
127
+ try:
128
+ await get_shared_ws_manager().emit_to(
129
+ NAMESPACE,
130
+ sid,
131
+ COMPUTER_USE_OP_EVENT,
132
+ payload,
133
+ handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
134
+ )
135
+ result = await asyncio.wait_for(future, timeout=COMPUTER_USE_OP_TIMEOUT)
136
+ finally:
137
+ clear_pending_computer_use_op(op_id)
138
+
139
+ if isinstance(result, dict):
140
+ return result
141
+ raise RuntimeError(f"Unexpected response format from CLI: {result!r}")
142
+
143
+ async def _maybe_attach_latest_capture(
144
+ self,
145
+ *,
146
+ action: str,
147
+ sid: str,
148
+ context_id: str,
149
+ result: dict[str, Any],
150
+ ) -> str:
151
+ if action not in _AUTO_CAPTURE_ACTIONS or not bool(result.get("ok")):
152
+ return ""
153
+
154
+ data = result.get("result")
155
+ result_data = dict(data) if isinstance(data, dict) else {}
156
+ session_id = str(result_data.get("session_id") or self.args.get("session_id") or "").strip()
157
+ if not session_id:
158
+ return ""
159
+
160
+ settle_seconds = self._auto_capture_settle_seconds(action)
161
+ if settle_seconds > 0:
162
+ await asyncio.sleep(settle_seconds)
163
+
164
+ capture_result = await self._dispatch_payload(
165
+ sid=sid,
166
+ payload={
167
+ "op_id": str(uuid.uuid4()),
168
+ "context_id": context_id,
169
+ "action": "capture",
170
+ "session_id": session_id,
171
+ },
172
+ )
173
+ if not bool(capture_result.get("ok")):
174
+ return f"Automatic screen refresh failed: {self._format_error(capture_result)}"
175
+
176
+ capture_data = capture_result.get("result")
177
+ if not isinstance(capture_data, dict):
178
+ return "Automatic screen refresh failed: missing capture payload."
179
+
180
+ self._record_capture(capture_data)
181
+ return "Latest screen attached."
182
+
183
+ def _auto_capture_settle_seconds(self, action: str) -> float:
184
+ if action == "start_session":
185
+ return _SETTLE_DELAY_START_SESSION
186
+ if action == "type" and self._coerce_bool(self.args.get("submit")):
187
+ return _SETTLE_DELAY_SUBMIT
188
+ if action != "key":
189
+ return 0.0
190
+
191
+ keyset = {key.lower() for key in self._requested_keys()}
192
+ if "super" in keyset or ("alt" in keyset and "tab" in keyset):
193
+ return _SETTLE_DELAY_GLOBAL_FOCUS
194
+ if keyset == {"enter"}:
195
+ return _SETTLE_DELAY_PLAIN_ENTER
196
+ return 0.0
197
+
198
+ def _requested_keys(self) -> list[str]:
199
+ keys_value = self.args.get("keys")
200
+ if isinstance(keys_value, (list, tuple)):
201
+ return [str(item).strip() for item in keys_value if str(item).strip()]
202
+ raw = str(keys_value or self.args.get("key", "") or "").strip()
203
+ if not raw:
204
+ return []
205
+ return [part.strip() for part in raw.split("+") if part.strip()]
206
+
207
+ def _build_payload(self, *, op_id: str, context_id: str, action: str) -> dict[str, Any]:
208
+ payload: dict[str, Any] = {
209
+ "op_id": op_id,
210
+ "context_id": context_id,
211
+ "action": action,
212
+ }
213
+ session_id = str(self.args.get("session_id", "") or "").strip()
214
+ if session_id:
215
+ payload["session_id"] = session_id
216
+
217
+ if action == "move":
218
+ payload["x"] = self.args.get("x")
219
+ payload["y"] = self.args.get("y")
220
+ elif action == "click":
221
+ if "x" in self.args:
222
+ payload["x"] = self.args.get("x")
223
+ if "y" in self.args:
224
+ payload["y"] = self.args.get("y")
225
+ payload["button"] = self.args.get("button", "left")
226
+ payload["count"] = self._coerce_int(self.args.get("count", 1), name="count")
227
+ elif action == "scroll":
228
+ payload["dx"] = self._coerce_int(self.args.get("dx", self.args.get("delta_x", 0)), name="dx")
229
+ payload["dy"] = self._coerce_int(self.args.get("dy", self.args.get("delta_y", 0)), name="dy")
230
+ elif action == "key":
231
+ if "keys" in self.args:
232
+ payload["keys"] = self.args.get("keys")
233
+ elif "key" in self.args:
234
+ payload["key"] = self.args.get("key")
235
+ elif action == "type":
236
+ payload["text"] = self.args.get("text", "")
237
+ if self._coerce_bool(self.args.get("submit")):
238
+ payload["submit"] = True
239
+
240
+ return payload
241
+
242
+ def _extract_result(self, action: str, result: Any) -> str:
243
+ if not isinstance(result, dict):
244
+ return f"Unexpected response format from CLI: {result!r}"
245
+
246
+ ok = bool(result.get("ok"))
247
+ data = result.get("result")
248
+
249
+ if not ok:
250
+ return self._format_error(result)
251
+
252
+ if not isinstance(data, dict):
253
+ data = {}
254
+
255
+ if action == "capture":
256
+ self._record_capture(data)
257
+ return "Current screen attached."
258
+ if action == "status":
259
+ return self._format_status(data)
260
+ if action == "start_session":
261
+ return (
262
+ f"Computer-use session started: session_id={data.get('session_id', '?')} "
263
+ f"size={data.get('width', '?')}x{data.get('height', '?')}"
264
+ )
265
+ if action == "stop_session":
266
+ return "Computer-use session stopped."
267
+ if action == "move":
268
+ return f"Pointer moved to x={data.get('x')} y={data.get('y')}."
269
+ if action == "click":
270
+ return f"Clicked {data.get('button', 'left')} button {data.get('count', 1)} time(s)."
271
+ if action == "scroll":
272
+ return f"Scrolled dx={data.get('dx', 0)} dy={data.get('dy', 0)}."
273
+ if action == "key":
274
+ keys = data.get("keys") or []
275
+ return f"Sent keys: {keys!r}."
276
+ if action == "type":
277
+ text = str(data.get("text", "") or "")
278
+ if data.get("submitted"):
279
+ return f"Typed {len(text)} character(s) and submitted."
280
+ return f"Typed {len(text)} character(s)."
281
+ return str(data)
282
+
283
+ def _format_error(self, result: dict[str, Any]) -> str:
284
+ error = str(result.get("error") or "Unknown error")
285
+ code = str(result.get("code") or "")
286
+ if code:
287
+ return f"{code}: {error}"
288
+ return error
289
+
290
+ def _format_status(self, data: dict[str, Any]) -> str:
291
+ status = str(data.get("status", "unknown") or "unknown")
292
+ trust_mode = str(data.get("trust_mode", "") or "")
293
+ backend_id = str(data.get("backend_id", "") or "").strip()
294
+ backend_family = str(data.get("backend_family", "") or "").strip()
295
+ active_contexts = data.get("active_contexts") or []
296
+ active_text = ", ".join(str(item) for item in active_contexts) if active_contexts else "none"
297
+ backend_text = ""
298
+ if backend_id:
299
+ backend_text = backend_id
300
+ if backend_family:
301
+ backend_text = f"{backend_text}/{backend_family}"
302
+ if backend_text:
303
+ return (
304
+ f"Computer use status={status}, trust_mode={trust_mode or 'unknown'}, "
305
+ f"backend={backend_text}, active_contexts={active_text}."
306
+ )
307
+ return f"Computer use status={status}, trust_mode={trust_mode or 'unknown'}, active_contexts={active_text}."
308
+
309
+ def _record_capture(self, data: dict[str, Any]) -> str:
310
+ image_b64 = self._capture_image_base64(data)
311
+ width = data.get("width", "?")
312
+ height = data.get("height", "?")
313
+ summary = f"Computer-use capture {width}x{height}."
314
+ content = [
315
+ {"type": "text", "text": summary},
316
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
317
+ ]
318
+ raw_message = history.RawMessage(raw_content=content, preview=summary)
319
+ self.agent.hist_add_message(False, content=raw_message, tokens=CAPTURE_TOKENS_ESTIMATE)
320
+ self._prune_prior_capture_history()
321
+ return summary
322
+
323
+ def _prune_prior_capture_history(self) -> None:
324
+ history_obj = getattr(self.agent, "history", None)
325
+ if history_obj is None:
326
+ return
327
+
328
+ capture_messages = self._collect_capture_messages(history_obj)
329
+ if len(capture_messages) <= 1:
330
+ return
331
+
332
+ latest = capture_messages[-1]
333
+ for message in capture_messages[:-1]:
334
+ if message is latest:
335
+ continue
336
+ preview = self._capture_preview_from_message(message)
337
+ if not preview:
338
+ continue
339
+ message.content = f"{preview} [embedded image removed]"
340
+ if hasattr(message, "summary"):
341
+ message.summary = ""
342
+ if hasattr(message, "calculate_tokens"):
343
+ message.tokens = message.calculate_tokens()
344
+
345
+ def _collect_capture_messages(self, history_obj: Any) -> list[Any]:
346
+ messages: list[Any] = []
347
+
348
+ def collect_topic(topic: Any) -> None:
349
+ topic_messages = getattr(topic, "messages", None)
350
+ if isinstance(topic_messages, list):
351
+ for message in topic_messages:
352
+ if self._capture_preview_from_message(message):
353
+ messages.append(message)
354
+
355
+ bulks = getattr(history_obj, "bulks", None)
356
+ if isinstance(bulks, list):
357
+ for bulk in bulks:
358
+ self._collect_capture_messages_from_record(bulk, messages)
359
+
360
+ topics = getattr(history_obj, "topics", None)
361
+ if isinstance(topics, list):
362
+ for topic in topics:
363
+ collect_topic(topic)
364
+
365
+ current = getattr(history_obj, "current", None)
366
+ if current is not None:
367
+ collect_topic(current)
368
+
369
+ return messages
370
+
371
+ def _collect_capture_messages_from_record(self, record: Any, messages: list[Any]) -> None:
372
+ topic_messages = getattr(record, "messages", None)
373
+ if isinstance(topic_messages, list):
374
+ for message in topic_messages:
375
+ if self._capture_preview_from_message(message):
376
+ messages.append(message)
377
+ return
378
+
379
+ nested_records = getattr(record, "records", None)
380
+ if isinstance(nested_records, list):
381
+ for nested in nested_records:
382
+ self._collect_capture_messages_from_record(nested, messages)
383
+
384
+ def _capture_preview_from_message(self, message: Any) -> str:
385
+ content = getattr(message, "content", None)
386
+ if not isinstance(content, dict):
387
+ return ""
388
+ raw_content = content.get("raw_content")
389
+ preview = content.get("preview")
390
+ if raw_content is None or not isinstance(preview, str):
391
+ return ""
392
+ if preview.startswith("Computer-use capture "):
393
+ return preview
394
+ return ""
395
+
396
+ def _capture_image_base64(self, data: dict[str, Any]) -> str:
397
+ inline_payload = str(data.get("png_base64", "") or "").strip()
398
+ if inline_payload:
399
+ try:
400
+ base64.b64decode(inline_payload, validate=True)
401
+ except Exception:
402
+ pass
403
+ else:
404
+ return inline_payload
405
+
406
+ image_path, _display_path = self._resolve_capture_path(data)
407
+ return base64.b64encode(image_path.read_bytes()).decode("utf-8")
408
+
409
+ def _resolve_capture_path(self, data: dict[str, Any]) -> tuple[Path, str]:
410
+ candidates = [
411
+ str(data.get("capture_path", "") or "").strip(),
412
+ str(data.get("container_path", "") or "").strip(),
413
+ str(data.get("host_path", "") or "").strip(),
414
+ ]
415
+ for candidate in candidates:
416
+ if candidate and Path(candidate).exists():
417
+ return Path(candidate), candidate
418
+ raise FileNotFoundError(
419
+ f"Capture artifact was not found in any advertised path: {candidates!r}"
420
+ )
421
+
422
+ def _coerce_int(self, value: object, *, name: str) -> int:
423
+ try:
424
+ return int(value or 0)
425
+ except (TypeError, ValueError) as exc:
426
+ raise ValueError(f"{name} must be an integer") from exc
427
+
428
+ def _coerce_bool(self, value: object) -> bool:
429
+ if isinstance(value, bool):
430
+ return value
431
+ if isinstance(value, (int, float)):
432
+ return bool(value)
433
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
skills/computer-use-remote/SKILL.md
new
+50
@@ -0,0 +1,50 @@
1
+---
2
+name: computer-use-remote
3
+description: Detailed operating guide for using computer_use_remote on the connected local machine. Load this skill before using computer_use_remote for desktop control, screenshots, menus, browser chrome, or other native UI tasks.
4
+version: 1.0.0
5
+author: Agent Zero Team
6
+tags: ["computer-use", "desktop", "local-ui", "screenshots", "native-ui"]
7
+trigger_patterns:
8
+ - "computer use"
9
+ - "computer-use"
10
+ - "computer_use_remote"
11
+ - "local desktop control"
12
+ - "control local browser"
13
+ - "click on screen"
14
+ - "native ui"
15
+allowed_tools:
16
+ - computer_use_remote
17
+ - code_execution_remote
18
+---
19
+
20
+# Computer Use Remote
21
+
22
+## When to Use
23
+
24
+Load this skill before using `computer_use_remote` for local desktop and native UI tasks on the connected machine.
25
+
26
+For ordinary website browsing, search, form filling, and web downloads, prefer `browser_agent` instead. If the user is flexible and the task is browser-only, guide them toward browser tools because they are usually more reliable and token-efficient than screenshot-driven computer use.
27
+
28
+## Core Loop
29
+
30
+1. Call `start_session` first.
31
+2. Decide from the latest screenshot, not from memory.
32
+3. Interactive actions (`move`, `click`, `scroll`, `key`, `type`) already attach a fresh screenshot after they run.
33
+4. Use `capture` only when you need another screenshot without taking an action.
34
+
35
+## Operating Rules
36
+
37
+- Only the latest screenshot or a definitive tool result counts as evidence.
38
+- Prefer keyboard actions over pointer actions whenever a reliable keyboard path exists.
39
+- When a menu or popup is open, treat it as the active UI and prefer keyboard navigation over clicking small transient rows by coordinate.
40
+- If a click dismisses a menu or popup without producing the expected next UI, treat that attempt as failed.
41
+- If the same approach has already failed twice without visible progress, switch strategy instead of repeating it.
42
+- Do not infer focus or task completion from chat logs, sidebars, tool summaries, or status text.
43
+- For browser-navigation tasks done through this tool, only claim success if the browser content area visibly shows the destination page or result.
44
+- Use `type(..., submit=true)` only for URL or navigation-style entry where Enter should fire immediately after typing.
45
+- Do not use `submit=true` for ordinary text fields. Type first, then send `enter` separately if needed.
46
+
47
+## Control Signals
48
+
49
+- Treat user interventions as high-priority control signals.
50
+- If the user says `stop`, `pause`, `abort`, `hold`, `don't continue`, or equivalent, halt immediately and do not use computer-use tools again until the user explicitly resumes.