codeaway 0.1.0__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.
- codeaway/__init__.py +4 -0
- codeaway/__main__.py +5 -0
- codeaway/agents.py +427 -0
- codeaway/cli.py +221 -0
- codeaway/config.py +157 -0
- codeaway/desktop.py +644 -0
- codeaway/server.py +673 -0
- codeaway/web/__init__.py +1 -0
- codeaway/web/app.js +747 -0
- codeaway/web/index.html +41 -0
- codeaway/web/setup.html +57 -0
- codeaway/web/style.css +280 -0
- codeaway-0.1.0.dist-info/METADATA +113 -0
- codeaway-0.1.0.dist-info/RECORD +17 -0
- codeaway-0.1.0.dist-info/WHEEL +4 -0
- codeaway-0.1.0.dist-info/entry_points.txt +2 -0
- codeaway-0.1.0.dist-info/licenses/LICENSE +21 -0
codeaway/__init__.py
ADDED
codeaway/__main__.py
ADDED
codeaway/agents.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from .desktop import (
|
|
10
|
+
AccessibilityAction,
|
|
11
|
+
AccessibilityNode,
|
|
12
|
+
DesktopBackend,
|
|
13
|
+
DesktopWindow,
|
|
14
|
+
FractionalRegion,
|
|
15
|
+
PixelPoint,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SurfaceMap:
|
|
21
|
+
sidebar: FractionalRegion
|
|
22
|
+
conversation: FractionalRegion
|
|
23
|
+
composer: FractionalRegion
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AgentTarget:
|
|
28
|
+
agent_id: str
|
|
29
|
+
window: DesktopWindow
|
|
30
|
+
surfaces: SurfaceMap
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TaskSnapshot:
|
|
35
|
+
title: str
|
|
36
|
+
state: Literal["done", "busy", "idle", "unknown"]
|
|
37
|
+
worktree: bool = False
|
|
38
|
+
selected: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ProjectSnapshot:
|
|
43
|
+
name: str
|
|
44
|
+
host: str | None
|
|
45
|
+
connected: bool
|
|
46
|
+
state: Literal["connected", "busy", "idle"]
|
|
47
|
+
expanded: bool
|
|
48
|
+
tasks: tuple[TaskSnapshot, ...]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class AgentSnapshot:
|
|
53
|
+
available: bool
|
|
54
|
+
source: str
|
|
55
|
+
projects: tuple[ProjectSnapshot, ...]
|
|
56
|
+
captured_at: str
|
|
57
|
+
error: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class NavigationAction:
|
|
62
|
+
kind: Literal["project", "task"]
|
|
63
|
+
project: str
|
|
64
|
+
title: str | None = None
|
|
65
|
+
expanded: bool | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ClickAction:
|
|
70
|
+
surface: Literal["sidebar", "conversation"]
|
|
71
|
+
x: float
|
|
72
|
+
y: float
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AgentBackend(Protocol):
|
|
76
|
+
id: str
|
|
77
|
+
|
|
78
|
+
def matches(self, window: DesktopWindow) -> bool: ...
|
|
79
|
+
|
|
80
|
+
def default_surfaces(self, window: DesktopWindow) -> SurfaceMap: ...
|
|
81
|
+
|
|
82
|
+
def inspect(self, desktop: DesktopBackend, target: AgentTarget) -> AgentSnapshot: ...
|
|
83
|
+
|
|
84
|
+
def navigate(
|
|
85
|
+
self, desktop: DesktopBackend, target: AgentTarget, action: NavigationAction
|
|
86
|
+
) -> None: ...
|
|
87
|
+
|
|
88
|
+
def click(
|
|
89
|
+
self, desktop: DesktopBackend, target: AgentTarget, action: ClickAction
|
|
90
|
+
) -> None: ...
|
|
91
|
+
|
|
92
|
+
def scroll(self, desktop: DesktopBackend, target: AgentTarget, amount: int) -> None: ...
|
|
93
|
+
|
|
94
|
+
def send(self, desktop: DesktopBackend, target: AgentTarget, text: str) -> None: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AgentRegistry:
|
|
98
|
+
def __init__(self, agents: list[AgentBackend] | tuple[AgentBackend, ...]):
|
|
99
|
+
self._agents = tuple(agents)
|
|
100
|
+
|
|
101
|
+
def discover(self, desktop: DesktopBackend) -> list[AgentTarget]:
|
|
102
|
+
windows = desktop.list_windows()
|
|
103
|
+
return [
|
|
104
|
+
AgentTarget(agent.id, window, agent.default_surfaces(window))
|
|
105
|
+
for window in windows
|
|
106
|
+
for agent in self._agents
|
|
107
|
+
if agent.matches(window)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
def resolve(
|
|
111
|
+
self,
|
|
112
|
+
desktop: DesktopBackend,
|
|
113
|
+
agent_id: str,
|
|
114
|
+
process_path: str,
|
|
115
|
+
title_hint: str | None,
|
|
116
|
+
surfaces: SurfaceMap,
|
|
117
|
+
) -> AgentTarget | None:
|
|
118
|
+
agent = next((agent for agent in self._agents if agent.id == agent_id), None)
|
|
119
|
+
if agent is None:
|
|
120
|
+
return None
|
|
121
|
+
windows = desktop.list_windows()
|
|
122
|
+
normalized_process_path = os.path.normcase(os.path.normpath(process_path))
|
|
123
|
+
candidates = [
|
|
124
|
+
window
|
|
125
|
+
for window in windows
|
|
126
|
+
if os.path.normcase(os.path.normpath(window.process_path))
|
|
127
|
+
== normalized_process_path
|
|
128
|
+
and agent.matches(window)
|
|
129
|
+
]
|
|
130
|
+
if title_hint is None:
|
|
131
|
+
return None
|
|
132
|
+
exact_matches = [window for window in candidates if window.title == title_hint]
|
|
133
|
+
if len(exact_matches) != 1:
|
|
134
|
+
return None
|
|
135
|
+
return AgentTarget(agent_id, exact_matches[0], surfaces)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TargetUnavailable(RuntimeError):
|
|
139
|
+
"""The selected window could not be activated safely."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class _ProjectRow:
|
|
144
|
+
node: AccessibilityNode
|
|
145
|
+
name: str
|
|
146
|
+
host: str | None
|
|
147
|
+
tasks: tuple[AccessibilityNode, ...]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CodexAgent:
|
|
151
|
+
id = "codex"
|
|
152
|
+
|
|
153
|
+
_PROJECT_CLASS_TOKENS = frozenset({"group/folder-row", "sidebar-item"})
|
|
154
|
+
_TASK_CLASS_TOKENS = frozenset({"sidebar-item", "py-row-y"})
|
|
155
|
+
_WORKTREE_CLASS_TOKENS = frozenset(
|
|
156
|
+
{"icon-2xs", "text-codex-description", "no-drag", "shrink-0"}
|
|
157
|
+
)
|
|
158
|
+
_BUSY_CLASS_TOKENS = frozenset({"icon-xs", "shrink-0"})
|
|
159
|
+
|
|
160
|
+
def matches(self, window: DesktopWindow) -> bool:
|
|
161
|
+
path = window.process_path.replace("/", "\\").casefold()
|
|
162
|
+
return "openai.codex_" in path or "\\openai\\codex\\" in path
|
|
163
|
+
|
|
164
|
+
def default_surfaces(self, window: DesktopWindow) -> SurfaceMap:
|
|
165
|
+
del window
|
|
166
|
+
return SurfaceMap(
|
|
167
|
+
sidebar=FractionalRegion(0.00, 0.03, 0.21, 0.97),
|
|
168
|
+
conversation=FractionalRegion(0.21, 0.08, 0.79, 0.82),
|
|
169
|
+
composer=FractionalRegion(0.33, 0.90, 0.55, 0.06),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _position(node: AccessibilityNode) -> tuple[int, int]:
|
|
174
|
+
return node.region.y, node.region.x
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _same_row(first: AccessibilityNode, second: AccessibilityNode) -> bool:
|
|
178
|
+
return (
|
|
179
|
+
first.region.y < second.region.y + second.region.height
|
|
180
|
+
and second.region.y < first.region.y + first.region.height
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _overlaps_horizontally(
|
|
185
|
+
first: AccessibilityNode, second: AccessibilityNode
|
|
186
|
+
) -> bool:
|
|
187
|
+
return (
|
|
188
|
+
first.region.x < second.region.x + second.region.width
|
|
189
|
+
and second.region.x < first.region.x + first.region.width
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _has_class_tokens(class_name: str, required_tokens: frozenset[str]) -> bool:
|
|
194
|
+
return required_tokens.issubset(class_name.split())
|
|
195
|
+
|
|
196
|
+
def _project_identity(
|
|
197
|
+
self, project: AccessibilityNode, nodes: list[AccessibilityNode]
|
|
198
|
+
) -> tuple[str, str | None]:
|
|
199
|
+
names: list[str] = []
|
|
200
|
+
for node in nodes:
|
|
201
|
+
if node.role.casefold() not in {"button", "buttoncontrol"}:
|
|
202
|
+
continue
|
|
203
|
+
if not self._same_row(project, node) or not self._overlaps_horizontally(
|
|
204
|
+
project, node
|
|
205
|
+
):
|
|
206
|
+
continue
|
|
207
|
+
for prefix in ("Start new chat in ", "Project actions for "):
|
|
208
|
+
if node.name.startswith(prefix):
|
|
209
|
+
names.append(node.name[len(prefix) :])
|
|
210
|
+
candidates = [
|
|
211
|
+
name
|
|
212
|
+
for name in names
|
|
213
|
+
if project.name == name or project.name.startswith(f"{name} ")
|
|
214
|
+
]
|
|
215
|
+
name = max(candidates, key=len) if candidates else project.name
|
|
216
|
+
suffix = project.name[len(name) :].strip()
|
|
217
|
+
return name, suffix or None
|
|
218
|
+
|
|
219
|
+
def _rows(self, nodes: list[AccessibilityNode]) -> tuple[_ProjectRow, ...]:
|
|
220
|
+
projects = sorted(
|
|
221
|
+
(
|
|
222
|
+
node
|
|
223
|
+
for node in nodes
|
|
224
|
+
if self._has_class_tokens(node.class_name, self._PROJECT_CLASS_TOKENS)
|
|
225
|
+
),
|
|
226
|
+
key=self._position,
|
|
227
|
+
)
|
|
228
|
+
tasks = sorted(
|
|
229
|
+
(
|
|
230
|
+
node
|
|
231
|
+
for node in nodes
|
|
232
|
+
if self._has_class_tokens(node.class_name, self._TASK_CLASS_TOKENS)
|
|
233
|
+
and not self._has_class_tokens(
|
|
234
|
+
node.class_name, self._PROJECT_CLASS_TOKENS
|
|
235
|
+
)
|
|
236
|
+
and node.name not in {"Pin chat", "Archive chat"}
|
|
237
|
+
),
|
|
238
|
+
key=self._position,
|
|
239
|
+
)
|
|
240
|
+
rows: list[_ProjectRow] = []
|
|
241
|
+
for index, project in enumerate(projects):
|
|
242
|
+
next_y = projects[index + 1].region.y if index + 1 < len(projects) else None
|
|
243
|
+
project_tasks = tuple(
|
|
244
|
+
task
|
|
245
|
+
for task in tasks
|
|
246
|
+
if task.region.y >= project.region.y
|
|
247
|
+
and (next_y is None or task.region.y < next_y)
|
|
248
|
+
)
|
|
249
|
+
name, host = self._project_identity(project, nodes)
|
|
250
|
+
rows.append(_ProjectRow(project, name, host, project_tasks))
|
|
251
|
+
return tuple(rows)
|
|
252
|
+
|
|
253
|
+
def _has_class_on_row(
|
|
254
|
+
self,
|
|
255
|
+
task: AccessibilityNode,
|
|
256
|
+
nodes: list[AccessibilityNode],
|
|
257
|
+
required_tokens: frozenset[str],
|
|
258
|
+
) -> bool:
|
|
259
|
+
return any(
|
|
260
|
+
self._has_class_tokens(node.class_name, required_tokens)
|
|
261
|
+
and self._same_row(task, node)
|
|
262
|
+
and self._overlaps_horizontally(task, node)
|
|
263
|
+
for node in nodes
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def _is_done(
|
|
268
|
+
task: AccessibilityNode,
|
|
269
|
+
sidebar_region,
|
|
270
|
+
sidebar_image: Image.Image | None,
|
|
271
|
+
) -> bool:
|
|
272
|
+
if sidebar_image is None:
|
|
273
|
+
return False
|
|
274
|
+
left = max(0, task.region.x - sidebar_region.x)
|
|
275
|
+
right = min(sidebar_image.width, task.region.x + task.region.width - sidebar_region.x)
|
|
276
|
+
top = max(0, task.region.y - sidebar_region.y)
|
|
277
|
+
bottom = min(sidebar_image.height, task.region.y + task.region.height - sidebar_region.y)
|
|
278
|
+
left = max(left, right - 64)
|
|
279
|
+
if left >= right or top >= bottom:
|
|
280
|
+
return False
|
|
281
|
+
blue_pixels = 0
|
|
282
|
+
pixels = sidebar_image.convert("RGB")
|
|
283
|
+
for y in range(top, bottom):
|
|
284
|
+
for x in range(left, right):
|
|
285
|
+
red, green, blue = pixels.getpixel((x, y))
|
|
286
|
+
if blue >= 150 and blue >= red + 45 and blue >= green + 20:
|
|
287
|
+
blue_pixels += 1
|
|
288
|
+
if blue_pixels >= 6:
|
|
289
|
+
return True
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
def inspect(self, desktop: DesktopBackend, target: AgentTarget) -> AgentSnapshot:
|
|
293
|
+
nodes = desktop.accessibility_tree(target.window)
|
|
294
|
+
sidebar_region = target.surfaces.sidebar.resolve(target.window.region)
|
|
295
|
+
sidebar_image = None
|
|
296
|
+
if desktop.is_foreground(target.window):
|
|
297
|
+
try:
|
|
298
|
+
sidebar_image = desktop.capture(sidebar_region)
|
|
299
|
+
except Exception:
|
|
300
|
+
sidebar_image = None
|
|
301
|
+
|
|
302
|
+
projects: list[ProjectSnapshot] = []
|
|
303
|
+
for row in self._rows(nodes):
|
|
304
|
+
tasks: list[TaskSnapshot] = []
|
|
305
|
+
for task in row.tasks:
|
|
306
|
+
busy = self._has_class_on_row(task, nodes, self._BUSY_CLASS_TOKENS)
|
|
307
|
+
state: Literal["done", "busy", "idle", "unknown"]
|
|
308
|
+
if self._is_done(task, sidebar_region, sidebar_image):
|
|
309
|
+
state = "done"
|
|
310
|
+
elif busy:
|
|
311
|
+
state = "busy"
|
|
312
|
+
else:
|
|
313
|
+
state = "unknown"
|
|
314
|
+
tasks.append(
|
|
315
|
+
TaskSnapshot(
|
|
316
|
+
title=task.name,
|
|
317
|
+
state=state,
|
|
318
|
+
worktree=self._has_class_on_row(
|
|
319
|
+
task, nodes, self._WORKTREE_CLASS_TOKENS
|
|
320
|
+
),
|
|
321
|
+
selected="bg-primary-ghost-hover" in task.class_name,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
connected = any(
|
|
325
|
+
node.role.casefold() in {"image", "imagecontrol"}
|
|
326
|
+
and node.name == "Connected"
|
|
327
|
+
and self._same_row(row.node, node)
|
|
328
|
+
and self._overlaps_horizontally(row.node, node)
|
|
329
|
+
for node in nodes
|
|
330
|
+
)
|
|
331
|
+
project_state: Literal["connected", "busy", "idle"]
|
|
332
|
+
if connected:
|
|
333
|
+
project_state = "connected"
|
|
334
|
+
elif any(task.state == "busy" for task in tasks):
|
|
335
|
+
project_state = "busy"
|
|
336
|
+
else:
|
|
337
|
+
project_state = "idle"
|
|
338
|
+
projects.append(
|
|
339
|
+
ProjectSnapshot(
|
|
340
|
+
name=row.name,
|
|
341
|
+
host=row.host,
|
|
342
|
+
connected=connected,
|
|
343
|
+
state=project_state,
|
|
344
|
+
expanded=row.node.expanded is True,
|
|
345
|
+
tasks=tuple(tasks),
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
return AgentSnapshot(
|
|
349
|
+
available=True,
|
|
350
|
+
source="accessibility+pixels" if sidebar_image is not None else "accessibility",
|
|
351
|
+
projects=tuple(projects),
|
|
352
|
+
captured_at=datetime.now(timezone.utc).isoformat(),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@staticmethod
|
|
356
|
+
def _activate(desktop: DesktopBackend, target: AgentTarget) -> None:
|
|
357
|
+
if not desktop.activate(target.window):
|
|
358
|
+
raise TargetUnavailable("the selected Codex window is unavailable")
|
|
359
|
+
|
|
360
|
+
def navigate(
|
|
361
|
+
self,
|
|
362
|
+
desktop: DesktopBackend,
|
|
363
|
+
target: AgentTarget,
|
|
364
|
+
action: NavigationAction,
|
|
365
|
+
) -> None:
|
|
366
|
+
self._activate(desktop, target)
|
|
367
|
+
rows = self._rows(desktop.accessibility_tree(target.window))
|
|
368
|
+
project = next((row for row in rows if row.name == action.project), None)
|
|
369
|
+
if project is None:
|
|
370
|
+
raise TargetUnavailable(f"project {action.project!r} is unavailable")
|
|
371
|
+
if action.kind == "project":
|
|
372
|
+
if action.expanded is None:
|
|
373
|
+
raise ValueError("project navigation requires an expanded state")
|
|
374
|
+
if project.node.expanded is action.expanded:
|
|
375
|
+
return
|
|
376
|
+
accessibility_action = (
|
|
377
|
+
AccessibilityAction.EXPAND
|
|
378
|
+
if action.expanded
|
|
379
|
+
else AccessibilityAction.COLLAPSE
|
|
380
|
+
)
|
|
381
|
+
desktop.accessibility_action(project.node, accessibility_action)
|
|
382
|
+
return
|
|
383
|
+
if action.title is None:
|
|
384
|
+
raise ValueError("task navigation requires a title")
|
|
385
|
+
task = next((task for task in project.tasks if task.name == action.title), None)
|
|
386
|
+
if task is None:
|
|
387
|
+
raise TargetUnavailable(f"task {action.title!r} is unavailable")
|
|
388
|
+
desktop.accessibility_action(task, AccessibilityAction.INVOKE)
|
|
389
|
+
|
|
390
|
+
def click(
|
|
391
|
+
self,
|
|
392
|
+
desktop: DesktopBackend,
|
|
393
|
+
target: AgentTarget,
|
|
394
|
+
action: ClickAction,
|
|
395
|
+
) -> None:
|
|
396
|
+
self._activate(desktop, target)
|
|
397
|
+
surface = getattr(target.surfaces, action.surface).resolve(target.window.region)
|
|
398
|
+
if surface.width <= 0 or surface.height <= 0:
|
|
399
|
+
raise TargetUnavailable("the calibrated surface has no usable pixels")
|
|
400
|
+
point = PixelPoint(
|
|
401
|
+
surface.x + min(surface.width - 1, math.floor(surface.width * action.x)),
|
|
402
|
+
surface.y + min(surface.height - 1, math.floor(surface.height * action.y)),
|
|
403
|
+
)
|
|
404
|
+
if action.surface == "sidebar" and surface.x + surface.width - point.x <= 64:
|
|
405
|
+
offset = max(24, round(target.window.region.width * 0.025))
|
|
406
|
+
point = PixelPoint(max(surface.x, point.x - offset), point.y)
|
|
407
|
+
desktop.click(target.window, point)
|
|
408
|
+
|
|
409
|
+
def scroll(
|
|
410
|
+
self,
|
|
411
|
+
desktop: DesktopBackend,
|
|
412
|
+
target: AgentTarget,
|
|
413
|
+
amount: int,
|
|
414
|
+
) -> None:
|
|
415
|
+
self._activate(desktop, target)
|
|
416
|
+
conversation = target.surfaces.conversation.resolve(target.window.region)
|
|
417
|
+
desktop.scroll(target.window, conversation.center, amount)
|
|
418
|
+
|
|
419
|
+
def send(
|
|
420
|
+
self,
|
|
421
|
+
desktop: DesktopBackend,
|
|
422
|
+
target: AgentTarget,
|
|
423
|
+
text: str,
|
|
424
|
+
) -> None:
|
|
425
|
+
self._activate(desktop, target)
|
|
426
|
+
composer = target.surfaces.composer.resolve(target.window.region)
|
|
427
|
+
desktop.paste_and_submit(target.window, composer.center, text)
|
codeaway/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ipaddress
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from http.server import ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Sequence
|
|
11
|
+
|
|
12
|
+
from .agents import AgentRegistry, CodexAgent
|
|
13
|
+
from .config import AppConfig, default_config_path, load_config, save_config
|
|
14
|
+
from .desktop import WindowsDesktop
|
|
15
|
+
from .server import AppState, Application, make_handler
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UnsupportedPlatform(RuntimeError):
|
|
19
|
+
"""The current platform has no supported desktop backend."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _desktop() -> WindowsDesktop:
|
|
23
|
+
if sys.platform != "win32":
|
|
24
|
+
raise UnsupportedPlatform(
|
|
25
|
+
"CodeAway v0.1 requires Windows with Codex Desktop."
|
|
26
|
+
)
|
|
27
|
+
return WindowsDesktop()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _registry(agents):
|
|
31
|
+
return AgentRegistry(agents)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Runtime:
|
|
36
|
+
server_factory: Callable = ThreadingHTTPServer
|
|
37
|
+
browser_open: Callable[[str], object] = webbrowser.open
|
|
38
|
+
config_path_factory: Callable[[], str | Path] = default_config_path
|
|
39
|
+
desktop_factory: Callable[[], object] = _desktop
|
|
40
|
+
registry_factory: Callable[[Sequence[object]], object] = _registry
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class StartResult:
|
|
45
|
+
exit_code: int
|
|
46
|
+
url: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _port(value: str) -> int:
|
|
50
|
+
port = int(value)
|
|
51
|
+
if not 1 <= port <= 65535:
|
|
52
|
+
raise argparse.ArgumentTypeError("port must be between 1 and 65535")
|
|
53
|
+
return port
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ipv4_address(value: str) -> str:
|
|
57
|
+
try:
|
|
58
|
+
address = ipaddress.ip_address(value)
|
|
59
|
+
except ValueError as error:
|
|
60
|
+
raise argparse.ArgumentTypeError("address must be an IPv4 address") from error
|
|
61
|
+
if not isinstance(address, ipaddress.IPv4Address):
|
|
62
|
+
raise argparse.ArgumentTypeError("CodeAway v0.1 requires an IPv4 address")
|
|
63
|
+
return str(address)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parser() -> argparse.ArgumentParser:
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="codeaway",
|
|
69
|
+
description="Control a local Codex Desktop window from your phone.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--ip",
|
|
73
|
+
type=_ipv4_address,
|
|
74
|
+
metavar="IPV4_ADDRESS",
|
|
75
|
+
help="IPv4 address to bind and cache",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("--port", type=_port, metavar="PORT", help="port to bind and cache")
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--no-browser",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="do not open the setup page in a laptop browser",
|
|
82
|
+
)
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_loopback(address: str) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
return ipaddress.ip_address(address).is_loopback
|
|
89
|
+
except ValueError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _base_url(address: str, port: int) -> str:
|
|
94
|
+
host = f"[{address}]" if ":" in address else address
|
|
95
|
+
return f"http://{host}:{port}/"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _print_bind_error(label: str, address: str, port: int, error: OSError) -> None:
|
|
99
|
+
print(
|
|
100
|
+
f"Could not bind {label} address {address}:{port}: {error}",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def start(
|
|
106
|
+
argv: Sequence[str] | None = None,
|
|
107
|
+
runtime: Runtime | None = None,
|
|
108
|
+
*,
|
|
109
|
+
_serve: bool = True,
|
|
110
|
+
) -> StartResult:
|
|
111
|
+
options = _parser().parse_args(list(sys.argv[1:] if argv is None else argv))
|
|
112
|
+
runtime = runtime or Runtime()
|
|
113
|
+
|
|
114
|
+
config_path = Path(runtime.config_path_factory())
|
|
115
|
+
config_existed = config_path.exists()
|
|
116
|
+
loaded = load_config(config_path)
|
|
117
|
+
for warning in loaded.warnings:
|
|
118
|
+
print(warning, file=sys.stderr)
|
|
119
|
+
config = loaded.config
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
desktop = runtime.desktop_factory()
|
|
123
|
+
except (OSError, RuntimeError) as error:
|
|
124
|
+
print(f"CodeAway cannot start: {error}", file=sys.stderr)
|
|
125
|
+
return StartResult(1, None)
|
|
126
|
+
|
|
127
|
+
agent = CodexAgent()
|
|
128
|
+
registry = runtime.registry_factory([agent])
|
|
129
|
+
target = None
|
|
130
|
+
if config.setup_complete:
|
|
131
|
+
assert config.selected_agent is not None
|
|
132
|
+
assert config.selected_window is not None
|
|
133
|
+
assert config.surfaces is not None
|
|
134
|
+
target = registry.resolve(
|
|
135
|
+
desktop,
|
|
136
|
+
config.selected_agent,
|
|
137
|
+
config.selected_window.process_path,
|
|
138
|
+
config.selected_window.title_hint,
|
|
139
|
+
config.surfaces,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
address = options.ip if options.ip is not None else config.bind_ip
|
|
143
|
+
port = options.port if options.port is not None else config.port
|
|
144
|
+
try:
|
|
145
|
+
parsed_address = ipaddress.ip_address(address)
|
|
146
|
+
except ValueError:
|
|
147
|
+
parsed_address = None
|
|
148
|
+
if not isinstance(parsed_address, ipaddress.IPv4Address):
|
|
149
|
+
print(
|
|
150
|
+
"CodeAway v0.1 requires an IPv4 bind address; "
|
|
151
|
+
"choose one with --ip IPV4_ADDRESS.",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
return StartResult(1, None)
|
|
155
|
+
state = AppState(config, target)
|
|
156
|
+
application = Application(
|
|
157
|
+
state,
|
|
158
|
+
registry,
|
|
159
|
+
{agent.id: agent},
|
|
160
|
+
desktop,
|
|
161
|
+
config_path,
|
|
162
|
+
)
|
|
163
|
+
handler = make_handler(application)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
server = runtime.server_factory((address, port), handler)
|
|
167
|
+
except OSError as error:
|
|
168
|
+
if options.ip is not None:
|
|
169
|
+
_print_bind_error("explicit", address, port, error)
|
|
170
|
+
return StartResult(1, None)
|
|
171
|
+
if _is_loopback(address):
|
|
172
|
+
source = "cached" if config_existed else "default"
|
|
173
|
+
_print_bind_error(source, address, port, error)
|
|
174
|
+
return StartResult(1, None)
|
|
175
|
+
print(
|
|
176
|
+
f"Cached address {address} could not be bound: {error}; "
|
|
177
|
+
"falling back to 127.0.0.1.",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
address = "127.0.0.1"
|
|
181
|
+
try:
|
|
182
|
+
server = runtime.server_factory((address, port), handler)
|
|
183
|
+
except OSError as fallback_error:
|
|
184
|
+
_print_bind_error("fallback", address, port, fallback_error)
|
|
185
|
+
return StartResult(1, None)
|
|
186
|
+
|
|
187
|
+
bound_port = int(server.server_address[1])
|
|
188
|
+
persisted = replace(config, bind_ip=address, port=bound_port)
|
|
189
|
+
try:
|
|
190
|
+
save_config(config_path, persisted)
|
|
191
|
+
except OSError as error:
|
|
192
|
+
server.server_close()
|
|
193
|
+
print(f"Could not save CodeAway configuration: {error}", file=sys.stderr)
|
|
194
|
+
return StartResult(1, None)
|
|
195
|
+
state.config = persisted
|
|
196
|
+
|
|
197
|
+
url = _base_url(address, bound_port)
|
|
198
|
+
if not _is_loopback(address):
|
|
199
|
+
print(
|
|
200
|
+
f"WARNING: Every device that can reach {url} receives full desktop input control."
|
|
201
|
+
)
|
|
202
|
+
print(f"CodeAway workspace: {url}")
|
|
203
|
+
if target is None and not options.no_browser:
|
|
204
|
+
runtime.browser_open(f"{url}setup")
|
|
205
|
+
|
|
206
|
+
if not _serve:
|
|
207
|
+
server.server_close()
|
|
208
|
+
return StartResult(0, url)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
server.serve_forever()
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
pass
|
|
214
|
+
finally:
|
|
215
|
+
server.shutdown()
|
|
216
|
+
server.server_close()
|
|
217
|
+
return StartResult(0, url)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
221
|
+
return start(argv).exit_code
|