crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""Broker Computer Use requests between Core sessions and Desktop hosts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fastapi import WebSocket
|
|
12
|
+
|
|
13
|
+
COMPUTER_USE_RELEASE_RETENTION_MS = 15_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
LeaseKey = tuple[str, str, str | None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PendingComputerUseRequest:
|
|
21
|
+
host_id: str
|
|
22
|
+
future: asyncio.Future[dict[str, Any]]
|
|
23
|
+
lease: LeaseKey
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ComputerUseHost:
|
|
28
|
+
host_id: str
|
|
29
|
+
websocket: WebSocket
|
|
30
|
+
enabled: bool
|
|
31
|
+
gui_available: bool
|
|
32
|
+
capabilities: dict[str, Any]
|
|
33
|
+
action_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
34
|
+
send_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ComputerUseBroker:
|
|
38
|
+
"""Tracks connected GUI hosts and dispatches one action at a time per host."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, timeout_seconds: float = 90.0) -> None:
|
|
41
|
+
self.timeout_seconds = timeout_seconds
|
|
42
|
+
self._hosts: dict[str, ComputerUseHost] = {}
|
|
43
|
+
self._pending: dict[str, PendingComputerUseRequest] = {}
|
|
44
|
+
self._active_leases: set[LeaseKey] = set()
|
|
45
|
+
# Keep the latest preview for each lease in the Gateway so a Desktop
|
|
46
|
+
# webview reload can reconstruct its UI. Released previews remain
|
|
47
|
+
# here only through the same 15-second retention window as Desktop.
|
|
48
|
+
self._lease_previews: dict[LeaseKey, dict[str, Any]] = {}
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _now_ms() -> int:
|
|
52
|
+
return int(time.time() * 1000)
|
|
53
|
+
|
|
54
|
+
def _purge_expired_previews(self, now_ms: int | None = None) -> None:
|
|
55
|
+
now = self._now_ms() if now_ms is None else now_ms
|
|
56
|
+
for lease, preview in list(self._lease_previews.items()):
|
|
57
|
+
deadline = preview.get("release_deadline_ms")
|
|
58
|
+
if (
|
|
59
|
+
lease not in self._active_leases
|
|
60
|
+
and isinstance(deadline, (int, float))
|
|
61
|
+
and deadline <= now
|
|
62
|
+
):
|
|
63
|
+
self._lease_previews.pop(lease, None)
|
|
64
|
+
|
|
65
|
+
def restorable_previews(self, host_id: str) -> list[dict[str, Any]]:
|
|
66
|
+
"""Return active or retained previews for a reconnecting Desktop host."""
|
|
67
|
+
self._purge_expired_previews()
|
|
68
|
+
return [
|
|
69
|
+
dict(preview)
|
|
70
|
+
for lease, preview in self._lease_previews.items()
|
|
71
|
+
if lease[0] == host_id
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def register(
|
|
75
|
+
self,
|
|
76
|
+
host_id: str,
|
|
77
|
+
websocket: WebSocket,
|
|
78
|
+
*,
|
|
79
|
+
enabled: bool,
|
|
80
|
+
gui_available: bool,
|
|
81
|
+
capabilities: dict[str, Any] | None = None,
|
|
82
|
+
) -> ComputerUseHost:
|
|
83
|
+
previous = self._hosts.get(host_id)
|
|
84
|
+
if previous is not None and previous.websocket is not websocket:
|
|
85
|
+
for request_id, pending in list(self._pending.items()):
|
|
86
|
+
if pending.host_id != host_id:
|
|
87
|
+
continue
|
|
88
|
+
self._pending.pop(request_id, None)
|
|
89
|
+
if not pending.future.done():
|
|
90
|
+
pending.future.set_exception(RuntimeError("Computer Use host reconnected"))
|
|
91
|
+
host = ComputerUseHost(
|
|
92
|
+
host_id=host_id,
|
|
93
|
+
websocket=websocket,
|
|
94
|
+
enabled=enabled,
|
|
95
|
+
gui_available=gui_available,
|
|
96
|
+
capabilities=dict(capabilities or {}),
|
|
97
|
+
)
|
|
98
|
+
self._hosts[host_id] = host
|
|
99
|
+
return host
|
|
100
|
+
|
|
101
|
+
def update_state(
|
|
102
|
+
self,
|
|
103
|
+
host_id: str,
|
|
104
|
+
websocket: WebSocket,
|
|
105
|
+
*,
|
|
106
|
+
enabled: bool,
|
|
107
|
+
gui_available: bool,
|
|
108
|
+
capabilities: dict[str, Any] | None = None,
|
|
109
|
+
) -> bool:
|
|
110
|
+
host = self._hosts.get(host_id)
|
|
111
|
+
if host is None or host.websocket is not websocket:
|
|
112
|
+
return False
|
|
113
|
+
host.enabled = enabled
|
|
114
|
+
host.gui_available = gui_available
|
|
115
|
+
if capabilities is not None:
|
|
116
|
+
host.capabilities = dict(capabilities)
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def unregister(self, host_id: str, websocket: WebSocket) -> None:
|
|
120
|
+
host = self._hosts.get(host_id)
|
|
121
|
+
if host is None or host.websocket is not websocket:
|
|
122
|
+
return
|
|
123
|
+
self._hosts.pop(host_id, None)
|
|
124
|
+
for request_id, pending in list(self._pending.items()):
|
|
125
|
+
if pending.host_id != host_id:
|
|
126
|
+
continue
|
|
127
|
+
self._pending.pop(request_id, None)
|
|
128
|
+
if not pending.future.done():
|
|
129
|
+
pending.future.set_exception(RuntimeError("Computer Use host disconnected"))
|
|
130
|
+
|
|
131
|
+
def is_available(self, host_id: str | None, mode: str | None = None) -> bool:
|
|
132
|
+
if not host_id:
|
|
133
|
+
return False
|
|
134
|
+
host = self._hosts.get(host_id)
|
|
135
|
+
if not (host and host.enabled and host.gui_available):
|
|
136
|
+
return False
|
|
137
|
+
if mode is None:
|
|
138
|
+
return True
|
|
139
|
+
supported_modes = host.capabilities.get("supported_modes")
|
|
140
|
+
if not isinstance(supported_modes, list):
|
|
141
|
+
# Hosts predating mode negotiation only implement the legacy
|
|
142
|
+
# foreground desktop behavior.
|
|
143
|
+
return mode == "foreground_desktop"
|
|
144
|
+
return mode in supported_modes
|
|
145
|
+
|
|
146
|
+
def environment(self, host_id: str | None) -> dict[str, Any]:
|
|
147
|
+
"""Transport-owned identity; model actions cannot select an environment."""
|
|
148
|
+
host = self._hosts.get(host_id or "")
|
|
149
|
+
if host is None or host.capabilities.get("environment") != "local_vm":
|
|
150
|
+
return {}
|
|
151
|
+
return {
|
|
152
|
+
key: host.capabilities.get(key)
|
|
153
|
+
for key in ("environment", "environment_id", "environment_name", "instance_id",
|
|
154
|
+
"shared_directory", "shared_read_only", "forwarded_ports")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def capabilities(self, host_id: str | None) -> dict[str, Any]:
|
|
158
|
+
host = self._hosts.get(host_id or "")
|
|
159
|
+
return dict(host.capabilities) if host and host.enabled else {}
|
|
160
|
+
|
|
161
|
+
def resolve(
|
|
162
|
+
self, host_id: str, request_id: str, result: dict[str, Any],
|
|
163
|
+
*, preview_screenshot: dict[str, Any] | None = None,
|
|
164
|
+
) -> bool:
|
|
165
|
+
pending = self._pending.get(request_id)
|
|
166
|
+
if pending is None or pending.host_id != host_id:
|
|
167
|
+
return False
|
|
168
|
+
self._pending.pop(request_id, None)
|
|
169
|
+
previous = self._lease_previews.get(pending.lease, {})
|
|
170
|
+
# This frame is exclusively for the human monitor and reconnect replay.
|
|
171
|
+
# Remove it before returning the tool result to Core/model history.
|
|
172
|
+
result = dict(result)
|
|
173
|
+
preview_frame = result.pop("preview_screenshot", None)
|
|
174
|
+
result.pop("preview_screenshot_error", None)
|
|
175
|
+
frame = result.get("screenshot") or preview_screenshot or preview_frame
|
|
176
|
+
accessibility = result.get("accessibility")
|
|
177
|
+
ax = accessibility if isinstance(accessibility, dict) else {}
|
|
178
|
+
elements = ax.get("elements")
|
|
179
|
+
element_count = ax.get("element_count", len(elements) if isinstance(elements, list) else 0)
|
|
180
|
+
has_ax = isinstance(accessibility, dict)
|
|
181
|
+
has_frame = bool(result.get("screenshot"))
|
|
182
|
+
keep_observation = not has_ax and not has_frame
|
|
183
|
+
self._lease_previews[pending.lease] = {
|
|
184
|
+
**previous,
|
|
185
|
+
"session_id": pending.lease[1],
|
|
186
|
+
"agent_id": pending.lease[2],
|
|
187
|
+
"mode": previous.get("mode", "background_app"),
|
|
188
|
+
"target_scope": previous.get("target_scope", "app_window"),
|
|
189
|
+
"delivery_policy": previous.get("delivery_policy", "allow_foreground"),
|
|
190
|
+
"status": "error" if result.get("ok") is False else "ready",
|
|
191
|
+
"action": str(result.get("action") or previous.get("action") or "unknown"),
|
|
192
|
+
"summary": str(
|
|
193
|
+
result.get("summary")
|
|
194
|
+
or result.get("error")
|
|
195
|
+
or previous.get("summary")
|
|
196
|
+
or "Computer Use action completed"
|
|
197
|
+
),
|
|
198
|
+
"frame": frame or previous.get("frame"),
|
|
199
|
+
"frame_updated_at_ms": self._now_ms() if frame else previous.get("frame_updated_at_ms"),
|
|
200
|
+
"observation_kind": result.get("observation_kind") or (
|
|
201
|
+
previous.get("observation_kind") if keep_observation else
|
|
202
|
+
"ax_and_screenshot" if has_ax and has_frame else "ax" if has_ax else "screenshot"
|
|
203
|
+
),
|
|
204
|
+
"ax_element_count": element_count if has_ax else previous.get("ax_element_count") if keep_observation else None,
|
|
205
|
+
"cursor": result.get("cursor") or previous.get("cursor"),
|
|
206
|
+
"updated_at_ms": self._now_ms(),
|
|
207
|
+
"release_deadline_ms": previous.get("release_deadline_ms"),
|
|
208
|
+
}
|
|
209
|
+
if not pending.future.done():
|
|
210
|
+
pending.future.set_result(result)
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
async def release(
|
|
214
|
+
self,
|
|
215
|
+
host_id: str,
|
|
216
|
+
*,
|
|
217
|
+
session_id: str,
|
|
218
|
+
agent_id: str | None = None,
|
|
219
|
+
all_agents: bool = False,
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""Tell a host that an agent/session no longer owns its preview.
|
|
222
|
+
|
|
223
|
+
Action completion is deliberately separate from release: a model may
|
|
224
|
+
spend an arbitrary amount of time thinking between Computer Use calls.
|
|
225
|
+
The Desktop starts its retention timer only after this message.
|
|
226
|
+
"""
|
|
227
|
+
if all_agents:
|
|
228
|
+
leases = {
|
|
229
|
+
lease for lease in self._active_leases
|
|
230
|
+
if lease[0] == host_id and lease[1] == session_id
|
|
231
|
+
}
|
|
232
|
+
else:
|
|
233
|
+
lease = (host_id, session_id, agent_id)
|
|
234
|
+
leases = {lease} if lease in self._active_leases else set()
|
|
235
|
+
if not leases:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
deadline_ms = self._now_ms() + COMPUTER_USE_RELEASE_RETENTION_MS
|
|
239
|
+
self._active_leases.difference_update(leases)
|
|
240
|
+
for lease in leases:
|
|
241
|
+
preview = self._lease_previews.get(lease)
|
|
242
|
+
if preview is not None:
|
|
243
|
+
preview["release_deadline_ms"] = deadline_ms
|
|
244
|
+
|
|
245
|
+
message: dict[str, Any] = {
|
|
246
|
+
"type": "computer_use_release",
|
|
247
|
+
"session_id": session_id,
|
|
248
|
+
"release_deadline_ms": deadline_ms,
|
|
249
|
+
}
|
|
250
|
+
if all_agents:
|
|
251
|
+
message["all_agents"] = True
|
|
252
|
+
elif agent_id is not None:
|
|
253
|
+
message["agent_id"] = agent_id
|
|
254
|
+
|
|
255
|
+
# The lifecycle state is released even if Desktop is briefly absent
|
|
256
|
+
# during a reload. A reconnect receives the retained preview and the
|
|
257
|
+
# original absolute deadline from restorable_previews().
|
|
258
|
+
host = self._hosts.get(host_id)
|
|
259
|
+
if host is not None:
|
|
260
|
+
async with host.send_lock:
|
|
261
|
+
if self._hosts.get(host_id) is host:
|
|
262
|
+
await host.websocket.send_json(message)
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
async def execute(
|
|
266
|
+
self,
|
|
267
|
+
host_id: str,
|
|
268
|
+
*,
|
|
269
|
+
session_id: str,
|
|
270
|
+
agent_id: str | None,
|
|
271
|
+
action: dict[str, Any],
|
|
272
|
+
mode: str | None = None,
|
|
273
|
+
target_scope: str | None = None,
|
|
274
|
+
delivery_policy: str = "allow_foreground",
|
|
275
|
+
) -> dict[str, Any]:
|
|
276
|
+
host = self._hosts.get(host_id)
|
|
277
|
+
if host is None or not host.enabled or not host.gui_available:
|
|
278
|
+
raise RuntimeError("Computer Use is unavailable or disabled")
|
|
279
|
+
if target_scope is None:
|
|
280
|
+
target_scope = "desktop" if mode == "foreground_desktop" else "app_window"
|
|
281
|
+
if target_scope not in ("app_window", "desktop") or delivery_policy not in (
|
|
282
|
+
"strict_background", "allow_foreground"
|
|
283
|
+
):
|
|
284
|
+
raise ValueError("Invalid Computer Use target scope or delivery policy")
|
|
285
|
+
expected_mode = "foreground_desktop" if target_scope == "desktop" else "background_app"
|
|
286
|
+
if mode is None:
|
|
287
|
+
mode = expected_mode
|
|
288
|
+
if mode != expected_mode:
|
|
289
|
+
raise ValueError("Conflicting Computer Use mode and target scope")
|
|
290
|
+
read_only = action.get("action") in ("observe", "list_windows", "list_displays", "wait")
|
|
291
|
+
|
|
292
|
+
async with host.action_lock:
|
|
293
|
+
request_id = str(uuid.uuid4())
|
|
294
|
+
try:
|
|
295
|
+
async with host.send_lock:
|
|
296
|
+
# Recheck immediately before sending: either lock can wait
|
|
297
|
+
# while the host disconnects or changes its capabilities.
|
|
298
|
+
if self._hosts.get(host_id) is not host or not host.enabled or not host.gui_available:
|
|
299
|
+
raise RuntimeError("Computer Use became unavailable")
|
|
300
|
+
supported_modes = host.capabilities.get("supported_modes")
|
|
301
|
+
if not isinstance(supported_modes, list):
|
|
302
|
+
supported_modes = ["foreground_desktop"]
|
|
303
|
+
if mode not in supported_modes:
|
|
304
|
+
raise RuntimeError(f"Computer Use mode '{mode}' is unavailable on this host")
|
|
305
|
+
error = None
|
|
306
|
+
if delivery_policy == "strict_background" and target_scope == "desktop":
|
|
307
|
+
error = "Desktop scope requires allow_foreground"
|
|
308
|
+
elif not read_only and host.capabilities.get("delivery_policy_version") != 1:
|
|
309
|
+
# Old hosts can silently ignore unknown policy fields.
|
|
310
|
+
error = "This host must be upgraded before it can enforce delivery policy"
|
|
311
|
+
elif (action.get("action") in ("press", "set_value", "perform_action")
|
|
312
|
+
or any(key in action for key in ("observation", "snapshot_id", "element_id", "ax_action"))):
|
|
313
|
+
if (target_scope != "app_window" or host.capabilities.get("environment") == "local_vm"
|
|
314
|
+
or host.capabilities.get("ax_protocol_version") != 1
|
|
315
|
+
or host.capabilities.get("ax_available") is not True):
|
|
316
|
+
error = "AX actions require an AX-capable macOS window host"
|
|
317
|
+
if error:
|
|
318
|
+
return {
|
|
319
|
+
"ok": False, "action": action.get("action"), "error": error, "summary": error,
|
|
320
|
+
"error_code": "background_delivery_unsupported", "action_dispatched": False,
|
|
321
|
+
"dispatch_succeeded": False, "effect_verified": False, "retry_safe": True,
|
|
322
|
+
"target_scope": target_scope, "delivery_policy": delivery_policy,
|
|
323
|
+
"focus_isolation": "unavailable",
|
|
324
|
+
}
|
|
325
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
326
|
+
lease = (host_id, session_id, agent_id)
|
|
327
|
+
self._pending[request_id] = PendingComputerUseRequest(
|
|
328
|
+
host_id=host_id,
|
|
329
|
+
future=future,
|
|
330
|
+
lease=lease,
|
|
331
|
+
)
|
|
332
|
+
self._purge_expired_previews()
|
|
333
|
+
self._active_leases.add(lease)
|
|
334
|
+
previous = self._lease_previews.get(lease, {})
|
|
335
|
+
self._lease_previews[lease] = {
|
|
336
|
+
**previous,
|
|
337
|
+
"session_id": session_id,
|
|
338
|
+
"agent_id": agent_id,
|
|
339
|
+
"mode": mode,
|
|
340
|
+
"target_scope": target_scope,
|
|
341
|
+
"delivery_policy": delivery_policy,
|
|
342
|
+
"status": "busy",
|
|
343
|
+
"action": str(action.get("action") or "unknown"),
|
|
344
|
+
"summary": "Executing…",
|
|
345
|
+
"updated_at_ms": self._now_ms(),
|
|
346
|
+
"release_deadline_ms": None,
|
|
347
|
+
}
|
|
348
|
+
await host.websocket.send_json({
|
|
349
|
+
"type": "computer_use_request",
|
|
350
|
+
"request_id": request_id,
|
|
351
|
+
"session_id": session_id,
|
|
352
|
+
"agent_id": agent_id,
|
|
353
|
+
"mode": mode,
|
|
354
|
+
"target_scope": target_scope,
|
|
355
|
+
"delivery_policy": delivery_policy,
|
|
356
|
+
"environment_id": host.capabilities.get("environment_id"),
|
|
357
|
+
"instance_id": host.capabilities.get("instance_id"),
|
|
358
|
+
"action": action,
|
|
359
|
+
})
|
|
360
|
+
return await asyncio.wait_for(future, timeout=self.timeout_seconds)
|
|
361
|
+
except asyncio.TimeoutError as exc:
|
|
362
|
+
raise RuntimeError("Computer Use host timed out; input may have arrived. Observe before retrying.") from exc
|
|
363
|
+
finally:
|
|
364
|
+
self._pending.pop(request_id, None)
|