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 ADDED
@@ -0,0 +1,4 @@
1
+ from importlib.metadata import version
2
+
3
+
4
+ __version__ = version("codeaway")
codeaway/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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