devops-bot-sdk 1.4.96__tar.gz → 1.4.101__tar.gz

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.
Files changed (44) hide show
  1. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/SOURCES.txt +6 -0
  4. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/client.py +22 -1
  7. devops_bot_sdk-1.4.101/sdk/design_verify/__init__.py +56 -0
  8. devops_bot_sdk-1.4.101/sdk/design_verify/assets.py +59 -0
  9. devops_bot_sdk-1.4.101/sdk/design_verify/bootstrap.py +170 -0
  10. devops_bot_sdk-1.4.101/sdk/design_verify/browser.py +195 -0
  11. devops_bot_sdk-1.4.101/sdk/design_verify/compare.py +150 -0
  12. devops_bot_sdk-1.4.101/sdk/design_verify/loop.py +204 -0
  13. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/ipc/handlers.py +65 -5
  14. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/README.md +0 -0
  15. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  16. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  17. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/requires.txt +0 -0
  18. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  19. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/collectors/__init__.py +0 -0
  20. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/collectors/files.py +0 -0
  21. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/collectors/process.py +0 -0
  22. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/collectors/screenshot.py +0 -0
  23. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/config.py +0 -0
  24. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/crucial.py +0 -0
  25. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/exceptions.py +0 -0
  26. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/git_ops.py +0 -0
  27. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/graphify.py +0 -0
  28. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/hooks/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/hooks/crucial_guard.py +0 -0
  30. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/ipc/__init__.py +0 -0
  31. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/ipc/electron_bridge.py +0 -0
  32. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/local_exec.py +0 -0
  33. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/models/__init__.py +0 -0
  34. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/models/envelope.py +0 -0
  35. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/models/requests.py +0 -0
  36. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/models/responses.py +0 -0
  37. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/models/snapshots.py +0 -0
  38. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/py.typed +0 -0
  39. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/run_auto.py +0 -0
  40. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/sse.py +0 -0
  41. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/test.py +0 -0
  42. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/test_pipeline.py +0 -0
  43. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/sdk/updater.py +0 -0
  44. {devops_bot_sdk-1.4.96 → devops_bot_sdk-1.4.101}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.96
3
+ Version: 1.4.101
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.96
3
+ Version: 1.4.101
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,12 @@ sdk/collectors/__init__.py
24
24
  sdk/collectors/files.py
25
25
  sdk/collectors/process.py
26
26
  sdk/collectors/screenshot.py
27
+ sdk/design_verify/__init__.py
28
+ sdk/design_verify/assets.py
29
+ sdk/design_verify/bootstrap.py
30
+ sdk/design_verify/browser.py
31
+ sdk/design_verify/compare.py
32
+ sdk/design_verify/loop.py
27
33
  sdk/hooks/__init__.py
28
34
  sdk/hooks/crucial_guard.py
29
35
  sdk/ipc/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.96"
7
+ version = "1.4.101"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.96
3
+ Version: 1.4.101
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.96"
33
+ __version__ = "1.4.101"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
58
58
  pass
59
59
 
60
60
 
61
- SDK_VERSION = "1.4.96"
61
+ SDK_VERSION = "1.4.101"
62
62
  _POLL_INTERVAL = 3.0
63
63
  _POLL_TIMEOUT = 600.0
64
64
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -195,6 +195,27 @@ class BackendClient:
195
195
  _check_status(resp.status_code, self._base_url)
196
196
  return [ThreadSummary(**t) for t in resp.json().get("threads", [])]
197
197
 
198
+ # ── Design image (for local visual verification) ───────────────────
199
+
200
+ async def fetch_design_image(
201
+ self, jira_task_id: str,
202
+ ) -> tuple[bytes, str] | None:
203
+ """GET /api/v1/orchestrate/design-image/{jira_task_id}.
204
+
205
+ Returns the task's design screenshot as ``(bytes, mime_type)``, or
206
+ ``None`` when the task has no image attachment (backend 404). The backend
207
+ proxies the bytes from Jira (Atlassian auth lives there, not here). The
208
+ caller is responsible for treating the bytes as EPHEMERAL — see
209
+ ``sdk.design_verify.ephemeral_design_image``.
210
+ """
211
+ url = self._url(f"/api/v1/orchestrate/design-image/{jira_task_id}")
212
+ async with httpx.AsyncClient(timeout=30.0) as client:
213
+ resp = await client.get(url, headers=self._headers)
214
+ if resp.status_code == 404:
215
+ return None
216
+ _check_status(resp.status_code, self._base_url)
217
+ return resp.content, resp.headers.get("content-type", "image/png")
218
+
198
219
  # ── Orchestrator ──────────────────────────────────────────────────
199
220
 
200
221
  async def orchestrator_run(
@@ -0,0 +1,56 @@
1
+ """Visual design verification & refinement loop (prototype).
2
+
3
+ Why this exists
4
+ ---------------
5
+ Design-to-code fidelity is capped when the system generates a frontend ONCE
6
+ from a JIRA screenshot and never *looks* at what it built. This package closes
7
+ that loop:
8
+
9
+ generate -> render in a real browser -> screenshot -> compare against the
10
+ original design -> feed the differences back -> correct -> repeat.
11
+
12
+ It is deliberately decoupled from the main pipeline (nothing here is imported by
13
+ the live `handlers`/`local_exec` flow yet) so it can be developed and tested in
14
+ isolation, then slotted into `_code_one_repo` once proven.
15
+
16
+ Two external pieces it leans on — both already available, no new Python deps:
17
+
18
+ * ``agent-browser`` — Vercel Labs' Rust browser-automation CLI. Renders the
19
+ generated UI in headless Chrome and screenshots it. Install separately; see
20
+ ``README.md``. This is the ONLY new tool requirement.
21
+ * the local ``claude`` CLI — reused via :func:`sdk.local_exec.run_claude_local`.
22
+ Its built-in ``Read`` tool reads image files natively, so it doubles as the
23
+ vision model that diffs the two screenshots. No `anthropic`/`openai` package
24
+ is added.
25
+
26
+ What the caller must supply
27
+ ---------------------------
28
+ The one input this repo does not currently receive is the **original design
29
+ image** (the JIRA attachment). The backend hydrates task *text* only — attachment
30
+ URLs never reach the SDK today (see ``sdk/models/requests.py``). To use this
31
+ loop, the backend/operator must provide a local path to that screenshot. That
32
+ integration is out of scope for this prototype; the loop takes the path as an
33
+ argument.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from .assets import ephemeral_design_image
38
+ from .bootstrap import BootstrapResult, ensure_agent_browser
39
+ from .browser import BrowserError, DiffResult, diff_baseline, render_screenshot, snapshot
40
+ from .compare import ComparisonReport, compare_design
41
+ from .loop import RefinementResult, verify_and_refine
42
+
43
+ __all__ = [
44
+ "render_screenshot",
45
+ "snapshot",
46
+ "diff_baseline",
47
+ "DiffResult",
48
+ "BrowserError",
49
+ "compare_design",
50
+ "ComparisonReport",
51
+ "verify_and_refine",
52
+ "RefinementResult",
53
+ "ensure_agent_browser",
54
+ "BootstrapResult",
55
+ "ephemeral_design_image",
56
+ ]
@@ -0,0 +1,59 @@
1
+ """Ephemeral handling of the design image fetched from the backend.
2
+
3
+ The JIRA design screenshot is downloaded from the backend proxy
4
+ (``BackendClient.fetch_design_image``) only for the duration of a verification
5
+ run and MUST NOT be persisted — same rule as the render/diff screenshots. This
6
+ module writes it to an OS temp file and deletes it on exit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import tempfile
13
+ from typing import AsyncIterator
14
+
15
+
16
+ def _ext_for_mime(mime: str) -> str:
17
+ m = (mime or "").lower()
18
+ if "png" in m:
19
+ return ".png"
20
+ if "jpeg" in m or "jpg" in m:
21
+ return ".jpg"
22
+ if "webp" in m:
23
+ return ".webp"
24
+ return ".img"
25
+
26
+
27
+ @contextlib.asynccontextmanager
28
+ async def ephemeral_design_image(client, jira_task_id: str) -> AsyncIterator[str | None]:
29
+ """Download a task's design image to a temp file for the duration of a `with`.
30
+
31
+ Yields the local path, or ``None`` when the task has no design image. The
32
+ file is deleted on exit no matter what — the design image is never persisted.
33
+
34
+ ``client`` is a ``BackendClient`` (anything exposing
35
+ ``async fetch_design_image(jira_task_id) -> (bytes, mime) | None``).
36
+
37
+ Example::
38
+
39
+ async with ephemeral_design_image(client, task_id) as design_path:
40
+ if design_path is None:
41
+ ... # no design attached — skip visual verification
42
+ else:
43
+ await verify_and_refine(design_path=design_path, ...)
44
+ """
45
+ img = await client.fetch_design_image(jira_task_id)
46
+ if not img:
47
+ yield None
48
+ return
49
+ data, mime = img
50
+ fd, path = tempfile.mkstemp(prefix="agentos_design_", suffix=_ext_for_mime(mime))
51
+ try:
52
+ with os.fdopen(fd, "wb") as f:
53
+ f.write(data)
54
+ yield path
55
+ finally:
56
+ try:
57
+ os.remove(path)
58
+ except OSError:
59
+ pass
@@ -0,0 +1,170 @@
1
+ """Per-user, on-demand installation of the ``agent-browser`` CLI.
2
+
3
+ Why this lives in the SDK
4
+ -------------------------
5
+ The visual verification loop renders and screenshots the generated frontend, and
6
+ that generation runs on the USER'S machine (the default `claude_code` engine runs
7
+ the local `claude` CLI locally — the backend only plans). So `agent-browser` must
8
+ be present on every user's system, not on the backend server.
9
+
10
+ `agent-browser` is a native Rust binary, not a pip package, so it cannot ride
11
+ along with ``pip install devops-bot-sdk``. Instead we bootstrap it lazily on
12
+ first use: check if it's already there, otherwise install it via the best channel
13
+ available on this machine, then fetch Chrome for Testing once. The whole thing is
14
+ idempotent and cached, and it NEVER hard-fails the SDK — if install isn't
15
+ possible (locked-down box, no package manager), it returns a reason and the
16
+ caller degrades to the text-only path.
17
+
18
+ Design choices
19
+ --------------
20
+ * **Lazy, not install-time.** A hard dependency at ``pip install`` time would
21
+ break every user on a machine without a Rust/Node toolchain or network. Lazy +
22
+ graceful-degrade keeps the SDK installable everywhere.
23
+ * **Cached.** A marker file under ``~/.agentos`` means the (subprocess-heavy)
24
+ checks run once, then it's a fast no-op on every subsequent run.
25
+ * **Opt-outable.** ``AGENTOS_ENABLE_DESIGN_VERIFY=0`` disables all of this.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ _BINARY = "agent-browser"
37
+ _MARKER = Path.home() / ".agentos" / ".agent_browser_ready"
38
+ # Installs pull binaries / Chrome over the network — give them real headroom.
39
+ _INSTALL_TIMEOUT_S = 600.0
40
+ _QUICK_TIMEOUT_S = 60.0
41
+
42
+
43
+ @dataclass
44
+ class BootstrapResult:
45
+ ok: bool
46
+ path: str | None # resolved agent-browser path when ok
47
+ reason: str # human-readable outcome (why it failed, if not ok)
48
+ installed: bool = False # True if we actually installed something this call
49
+
50
+
51
+ def _run(cmd: list[str], timeout: float) -> tuple[bool, str]:
52
+ """Run a setup command; return (success, combined-output). Never raises."""
53
+ try:
54
+ proc = subprocess.run(
55
+ cmd, capture_output=True, text=True, timeout=timeout,
56
+ )
57
+ except FileNotFoundError:
58
+ return False, f"{cmd[0]} not found"
59
+ except subprocess.TimeoutExpired:
60
+ return False, f"{' '.join(cmd)} timed out after {timeout:.0f}s"
61
+ except Exception as exc: # noqa: BLE001 — setup must never crash the SDK
62
+ return False, str(exc)
63
+ out = (proc.stdout or "") + (proc.stderr or "")
64
+ return proc.returncode == 0, out.strip()
65
+
66
+
67
+ def _disabled() -> bool:
68
+ return os.getenv("AGENTOS_ENABLE_DESIGN_VERIFY", "1").lower() in ("0", "false", "no")
69
+
70
+
71
+ def _install_binary() -> tuple[bool, str]:
72
+ """Install the agent-browser binary via the best available channel.
73
+
74
+ Order: npm (Node is almost always present — it's how the `claude` CLI itself
75
+ is installed), then Homebrew on macOS, then Cargo. Each is tried only if its
76
+ package manager exists. Prebuilt-binary download is intentionally omitted here
77
+ (GitHub release asset plumbing) — document it as the manual fallback.
78
+ """
79
+ attempts: list[str] = []
80
+
81
+ if shutil.which("npm"):
82
+ ok, out = _run(["npm", "install", "-g", _BINARY], _INSTALL_TIMEOUT_S)
83
+ if ok and shutil.which(_BINARY):
84
+ return True, "installed via npm"
85
+ attempts.append(f"npm: {out[-200:]}")
86
+
87
+ if sys.platform == "darwin" and shutil.which("brew"):
88
+ ok, out = _run(["brew", "install", _BINARY], _INSTALL_TIMEOUT_S)
89
+ if ok and shutil.which(_BINARY):
90
+ return True, "installed via brew"
91
+ attempts.append(f"brew: {out[-200:]}")
92
+
93
+ if shutil.which("cargo"):
94
+ ok, out = _run(["cargo", "install", _BINARY], _INSTALL_TIMEOUT_S)
95
+ if ok and shutil.which(_BINARY):
96
+ return True, "installed via cargo"
97
+ attempts.append(f"cargo: {out[-200:]}")
98
+
99
+ if not attempts:
100
+ return False, (
101
+ "no supported installer found (need npm, brew, or cargo). Install "
102
+ f"`{_BINARY}` manually — see sdk/design_verify/README.md."
103
+ )
104
+ return False, "install attempts failed -> " + " | ".join(attempts)
105
+
106
+
107
+ def _ensure_chrome() -> tuple[bool, str]:
108
+ """Ensure Chrome for Testing is present (`agent-browser install`)."""
109
+ # `doctor` reports daemon/browser state; if Chrome is already there this is fast.
110
+ ok, out = _run([_BINARY, "doctor"], _QUICK_TIMEOUT_S)
111
+ if ok and "chrome" in out.lower() and "not" not in out.lower():
112
+ return True, "chrome already present"
113
+ cmd = [_BINARY, "install"]
114
+ if sys.platform.startswith("linux"):
115
+ cmd.append("--with-deps") # pull system libs Chrome needs on Linux
116
+ ok, out = _run(cmd, _INSTALL_TIMEOUT_S)
117
+ return ok, out[-200:] if out else ("chrome installed" if ok else "chrome install failed")
118
+
119
+
120
+ def ensure_agent_browser(*, force: bool = False) -> BootstrapResult:
121
+ """Make ``agent-browser`` usable on this machine; idempotent and cached.
122
+
123
+ Returns a :class:`BootstrapResult`. On success ``path`` is the binary location.
124
+ Never raises — callers gate the verification loop on ``result.ok``.
125
+ """
126
+ if _disabled():
127
+ return BootstrapResult(False, None, "design verification disabled "
128
+ "(AGENTOS_ENABLE_DESIGN_VERIFY=0)")
129
+
130
+ existing = shutil.which(_BINARY)
131
+ if existing and _MARKER.exists() and not force:
132
+ return BootstrapResult(True, existing, "already bootstrapped (cached)")
133
+
134
+ installed = False
135
+ if not existing:
136
+ ok, reason = _install_binary()
137
+ if not ok:
138
+ return BootstrapResult(False, None, reason)
139
+ installed = True
140
+ existing = shutil.which(_BINARY)
141
+
142
+ chrome_ok, chrome_reason = _ensure_chrome()
143
+ if not chrome_ok:
144
+ return BootstrapResult(False, existing,
145
+ f"agent-browser installed but Chrome setup failed: {chrome_reason}",
146
+ installed=installed)
147
+
148
+ try:
149
+ _MARKER.parent.mkdir(parents=True, exist_ok=True)
150
+ _MARKER.write_text("ok")
151
+ except OSError:
152
+ pass # marker is an optimization; not being able to write it is harmless
153
+
154
+ return BootstrapResult(True, existing, "ready", installed=installed)
155
+
156
+
157
+ def main() -> int:
158
+ """CLI entry point: ``python -m sdk.design_verify.bootstrap``.
159
+
160
+ Lets ops/onboarding provision agent-browser for a user ahead of time instead
161
+ of waiting for the first design ticket.
162
+ """
163
+ res = ensure_agent_browser(force="--force" in sys.argv)
164
+ print(f"agent-browser: {'OK' if res.ok else 'UNAVAILABLE'} — {res.reason}"
165
+ + (f" ({res.path})" if res.path else ""))
166
+ return 0 if res.ok else 1
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())
@@ -0,0 +1,195 @@
1
+ """Thin async wrapper around the ``agent-browser`` CLI.
2
+
3
+ Only the two operations the verification loop needs are exposed: render a URL and
4
+ screenshot it to a file, and grab a structured accessibility snapshot. The
5
+ daemon that ``agent-browser`` runs behind the scenes starts on first command and
6
+ persists between calls, so successive screenshots in a refinement loop are cheap.
7
+
8
+ Command syntax mirrors the upstream CLI (vercel-labs/agent-browser):
9
+
10
+ agent-browser open <url> # navigate (aliases: navigate, goto)
11
+ agent-browser screenshot <path> # --full, --screenshot-format png|jpeg
12
+ agent-browser snapshot --json # accessibility tree with @e refs
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import re
18
+ import shutil
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ _BINARY = "agent-browser"
23
+ # A single browser op should never hang the loop; the daemon is local.
24
+ _CMD_TIMEOUT_S = 60.0
25
+
26
+ # agent-browser's `diff screenshot` prints human-readable lines; parse defensively
27
+ # rather than assume a stable JSON shape. Captures "12.34%" and "1234 pixels".
28
+ _PCT_RE = re.compile(r"([\d.]+)\s*%")
29
+ _PIXELS_RE = re.compile(r"([\d,]+)\s*(?:different\s+)?pixels", re.IGNORECASE)
30
+
31
+
32
+ class BrowserError(RuntimeError):
33
+ """Raised when the agent-browser CLI is missing or a command fails."""
34
+
35
+
36
+ @dataclass
37
+ class DiffResult:
38
+ """Outcome of a native `agent-browser diff screenshot` pixel comparison."""
39
+ mismatch_pct: float | None # None when a pixel % could not be determined
40
+ diff_pixels: int | None
41
+ diff_image: str | None # path to the red-highlighted diff image
42
+ dimension_mismatch: bool # baseline/current sizes differ -> no pixel diff
43
+ raw: str = ""
44
+
45
+
46
+ def is_available() -> bool:
47
+ """True if the ``agent-browser`` binary is on PATH."""
48
+ return shutil.which(_BINARY) is not None
49
+
50
+
51
+ async def _run(*args: str) -> str:
52
+ """Run one agent-browser subcommand, returning stdout (text)."""
53
+ if not is_available():
54
+ raise BrowserError(
55
+ f"`{_BINARY}` not found on PATH. Install it (see "
56
+ "sdk/design_verify/README.md) — it is the only external dependency "
57
+ "of the design verification loop."
58
+ )
59
+ proc = await asyncio.create_subprocess_exec(
60
+ _BINARY, *args,
61
+ stdout=asyncio.subprocess.PIPE,
62
+ stderr=asyncio.subprocess.PIPE,
63
+ )
64
+ try:
65
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=_CMD_TIMEOUT_S)
66
+ except asyncio.TimeoutError as exc: # pragma: no cover - defensive
67
+ proc.kill()
68
+ raise BrowserError(f"`{_BINARY} {args[0]}` timed out after {_CMD_TIMEOUT_S}s") from exc
69
+ if proc.returncode != 0:
70
+ raise BrowserError(
71
+ f"`{_BINARY} {' '.join(args)}` failed (exit {proc.returncode}): "
72
+ f"{err.decode(errors='replace').strip()}"
73
+ )
74
+ return out.decode(errors="replace")
75
+
76
+
77
+ async def render_screenshot(
78
+ url: str,
79
+ out_path: str | Path,
80
+ *,
81
+ full_page: bool = False,
82
+ fmt: str = "png",
83
+ ) -> Path:
84
+ """Navigate to ``url`` and screenshot it to ``out_path``.
85
+
86
+ ``full_page`` captures the entire scrollable page (``--full``); the default
87
+ captures just the viewport, which usually matches a design mock's framing
88
+ better. Returns the resolved output path.
89
+ """
90
+ out = Path(out_path).expanduser().resolve()
91
+ out.parent.mkdir(parents=True, exist_ok=True)
92
+ await _run("open", url)
93
+ args = ["screenshot", str(out), "--screenshot-format", fmt]
94
+ if full_page:
95
+ args.append("--full")
96
+ await _run(*args)
97
+ if not out.exists():
98
+ raise BrowserError(f"screenshot reported success but {out} was not written")
99
+ return out
100
+
101
+
102
+ async def snapshot(url: str | None = None, *, interactive_only: bool = False) -> str:
103
+ """Return the page's accessibility tree as JSON text.
104
+
105
+ Lets the comparison assert *structure* (roles, labels, hierarchy) — not just
106
+ pixels. If ``url`` is given, navigates first; otherwise snapshots the current
107
+ page. Useful as a secondary signal when a pixel diff is ambiguous.
108
+ """
109
+ if url:
110
+ await _run("open", url)
111
+ args = ["snapshot", "--json"]
112
+ if interactive_only:
113
+ args.append("--interactive")
114
+ return await _run(*args)
115
+
116
+
117
+ async def diff_baseline(
118
+ baseline: str | Path,
119
+ *,
120
+ out_path: str | Path,
121
+ url: str | None = None,
122
+ threshold: float = 0.1,
123
+ ) -> DiffResult:
124
+ """Pixel-diff the current page against a ``baseline`` image.
125
+
126
+ Runs ``agent-browser diff screenshot --baseline <baseline> -o <out> -t <thr>``,
127
+ which screenshots the live page and compares it to ``baseline`` at the pixel
128
+ level, writing a red-highlighted diff image to ``out_path``. If ``url`` is
129
+ given, navigates there first.
130
+
131
+ This is a *quantitative, localizing* signal — mismatch % and a highlight
132
+ image — NOT a semantic judgement. Baselines whose dimensions differ from the
133
+ render yield ``dimension_mismatch=True`` (the CLI refuses a pixel compare);
134
+ that is expected for design mocks and is handled gracefully by the caller,
135
+ which falls back to the vision comparison.
136
+
137
+ A nonzero exit here means "differences found," not failure, so — unlike the
138
+ other commands — we do not raise on it; we parse the reported metrics.
139
+ """
140
+ if not is_available():
141
+ raise BrowserError(
142
+ f"`{_BINARY}` not found on PATH. Install it (see "
143
+ "sdk/design_verify/README.md)."
144
+ )
145
+ base = Path(baseline).expanduser().resolve()
146
+ if not base.exists():
147
+ raise BrowserError(f"baseline image not found: {base}")
148
+ out = Path(out_path).expanduser().resolve()
149
+ out.parent.mkdir(parents=True, exist_ok=True)
150
+
151
+ if url:
152
+ await _run("open", url)
153
+
154
+ proc = await asyncio.create_subprocess_exec(
155
+ _BINARY, "diff", "screenshot",
156
+ "--baseline", str(base),
157
+ "-o", str(out),
158
+ "-t", str(threshold),
159
+ stdout=asyncio.subprocess.PIPE,
160
+ stderr=asyncio.subprocess.PIPE,
161
+ )
162
+ try:
163
+ raw_out, raw_err = await asyncio.wait_for(
164
+ proc.communicate(), timeout=_CMD_TIMEOUT_S
165
+ )
166
+ except asyncio.TimeoutError as exc: # pragma: no cover - defensive
167
+ proc.kill()
168
+ raise BrowserError(f"`{_BINARY} diff screenshot` timed out") from exc
169
+
170
+ text = (raw_out.decode(errors="replace") + "\n" + raw_err.decode(errors="replace")).strip()
171
+ dim_mismatch = "dimension mismatch" in text.lower()
172
+ pct = None
173
+ px = None
174
+ if not dim_mismatch:
175
+ m = _PCT_RE.search(text)
176
+ if m:
177
+ pct = float(m.group(1))
178
+ m = _PIXELS_RE.search(text)
179
+ if m:
180
+ px = int(m.group(1).replace(",", ""))
181
+ return DiffResult(
182
+ mismatch_pct=pct,
183
+ diff_pixels=px,
184
+ diff_image=str(out) if out.exists() else None,
185
+ dimension_mismatch=dim_mismatch,
186
+ raw=text,
187
+ )
188
+
189
+
190
+ async def close() -> None:
191
+ """Close the active browser session (best-effort)."""
192
+ try:
193
+ await _run("close")
194
+ except BrowserError:
195
+ pass
@@ -0,0 +1,150 @@
1
+ """Vision comparison of a rendered UI against the original design.
2
+
3
+ Instead of adding an `anthropic`/`openai` dependency, this reuses the local
4
+ ``claude`` CLI (via :func:`sdk.local_exec.run_claude_local`). The CLI's built-in
5
+ ``Read`` tool reads image files natively, so we point it at BOTH the original
6
+ design screenshot and the freshly rendered one and ask it to produce a strict
7
+ JSON diff. This keeps the loop dependency-free and consistent with how the rest
8
+ of the SDK already shells out to the local agent.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from sdk.local_exec import run_claude_local
17
+
18
+ # Only Read is needed — the comparison must never edit files.
19
+ _COMPARE_TOOLS = ["Read"]
20
+
21
+
22
+ @dataclass
23
+ class Discrepancy:
24
+ area: str # e.g. "header", "primary CTA", "card grid spacing"
25
+ severity: str # "high" | "medium" | "low"
26
+ expected: str # what the design shows
27
+ actual: str # what the rendered UI shows
28
+
29
+
30
+ @dataclass
31
+ class ComparisonReport:
32
+ fidelity_score: int # 0-100, higher = closer to design
33
+ discrepancies: list[Discrepancy] = field(default_factory=list)
34
+ correction_prompt: str = "" # concrete fix instructions for the coder
35
+ raw: str = "" # raw model output (for debugging)
36
+
37
+ @property
38
+ def passed(self) -> bool:
39
+ return self.fidelity_score >= 0 # threshold applied by the loop, not here
40
+
41
+
42
+ def _build_compare_prompt(
43
+ design_path: Path,
44
+ rendered_path: Path,
45
+ diff_path: Path | None = None,
46
+ diff_note: str = "",
47
+ ) -> str:
48
+ """Prompt the local agent to diff two images and emit strict JSON only."""
49
+ diff_block = ""
50
+ if diff_path is not None:
51
+ diff_block = (
52
+ f" 3. PIXEL DIFF (changed regions highlighted in red): {diff_path}\n"
53
+ )
54
+ hint = ""
55
+ if diff_note:
56
+ hint = (
57
+ "\nA pixel-level comparison was already run: "
58
+ f"{diff_note} Read the PIXEL DIFF image to see WHERE the pixels "
59
+ "differ (red = changed), and use it to localize your review — but "
60
+ "judge semantically, not by raw pixel count (fonts/anti-aliasing/"
61
+ "placeholder content inflate raw pixel diffs).\n"
62
+ )
63
+ return (
64
+ "You are a meticulous UI design reviewer. Images are on disk:\n"
65
+ f" 1. TARGET DESIGN (the intended look): {design_path}\n"
66
+ f" 2. CURRENT RENDER (what our generated frontend actually looks like): "
67
+ f"{rendered_path}\n"
68
+ f"{diff_block}"
69
+ f"{hint}\n"
70
+ "Use the Read tool to open and view the images. Then compare the CURRENT "
71
+ "RENDER against the TARGET DESIGN across: overall layout & structure, "
72
+ "spacing/alignment, colors, typography (family/size/weight), component "
73
+ "presence and ordering, iconography, and content/labels.\n\n"
74
+ "Output ONLY a single JSON object (no prose, no markdown fences) with this "
75
+ "exact shape:\n"
76
+ "{\n"
77
+ ' "fidelity_score": <int 0-100, how closely the render matches the design>,\n'
78
+ ' "discrepancies": [\n'
79
+ ' {"area": "<region/component>", "severity": "high|medium|low", '
80
+ '"expected": "<what the design shows>", "actual": "<what the render shows>"}\n'
81
+ " ],\n"
82
+ ' "correction_prompt": "<a concise, imperative set of concrete code changes '
83
+ "that would close the gaps — reference components, colors (hex if visible), "
84
+ 'spacing, and layout explicitly so a coding agent can act on it directly>"\n'
85
+ "}\n\n"
86
+ "Rank discrepancies most-severe first. If the render already matches "
87
+ "closely, return a high score, an empty discrepancies list, and an empty "
88
+ "correction_prompt."
89
+ )
90
+
91
+
92
+ def _extract_json(text: str) -> dict:
93
+ """Pull the first balanced JSON object out of the model's output."""
94
+ start = text.find("{")
95
+ end = text.rfind("}")
96
+ if start == -1 or end == -1 or end <= start:
97
+ raise ValueError(f"no JSON object found in comparison output: {text[:200]!r}")
98
+ return json.loads(text[start : end + 1])
99
+
100
+
101
+ async def compare_design(
102
+ design_path: str | Path,
103
+ rendered_path: str | Path,
104
+ *,
105
+ work_dir: str | Path,
106
+ diff_path: str | Path | None = None,
107
+ diff_note: str = "",
108
+ ) -> ComparisonReport:
109
+ """Diff a rendered screenshot against the target design; return a report.
110
+
111
+ ``work_dir`` is the cwd handed to the local ``claude`` CLI — any readable dir
112
+ works; the image paths are passed absolute so location does not matter.
113
+
114
+ ``diff_path``/``diff_note`` optionally pass a pre-computed pixel-diff image
115
+ (from :func:`sdk.design_verify.browser.diff_baseline`) and its metrics, so
116
+ the vision model can localize its review to the highlighted regions.
117
+ """
118
+ design = Path(design_path).expanduser().resolve()
119
+ rendered = Path(rendered_path).expanduser().resolve()
120
+ for p in (design, rendered):
121
+ if not p.exists():
122
+ raise FileNotFoundError(f"image not found: {p}")
123
+ diff = Path(diff_path).expanduser().resolve() if diff_path else None
124
+ if diff is not None and not diff.exists():
125
+ diff = None # diff is a best-effort aid; never fail the review on it
126
+
127
+ result = await run_claude_local(
128
+ _build_compare_prompt(design, rendered, diff, diff_note),
129
+ str(work_dir),
130
+ allowed_tools=_COMPARE_TOOLS,
131
+ )
132
+ if not result.get("ok"):
133
+ raise RuntimeError(f"comparison run failed: {result.get('error')}")
134
+
135
+ raw = result.get("result", "") or ""
136
+ data = _extract_json(raw)
137
+ return ComparisonReport(
138
+ fidelity_score=int(data.get("fidelity_score", 0)),
139
+ discrepancies=[
140
+ Discrepancy(
141
+ area=str(d.get("area", "")),
142
+ severity=str(d.get("severity", "")),
143
+ expected=str(d.get("expected", "")),
144
+ actual=str(d.get("actual", "")),
145
+ )
146
+ for d in data.get("discrepancies", [])
147
+ ],
148
+ correction_prompt=str(data.get("correction_prompt", "")),
149
+ raw=raw,
150
+ )
@@ -0,0 +1,204 @@
1
+ """The closed refinement loop: render -> compare -> correct -> repeat.
2
+
3
+ This is the piece that turns a one-shot design-to-code generation into a
4
+ self-correcting one. It assumes the frontend has already been generated once and
5
+ is being served at ``preview_url`` (a local dev server). Each iteration:
6
+
7
+ 1. screenshot the live preview with agent-browser
8
+ 2. compare it against the target design (local claude vision diff)
9
+ 3. stop if fidelity >= threshold or we've run out of iterations
10
+ 4. otherwise feed the correction prompt back to the coding agent, which edits
11
+ the real files; then loop (the caller's dev server hot-reloads)
12
+
13
+ The coding step is injectable (``coder``) so the real pipeline can plug in its
14
+ session-resuming ``run_claude_local`` call (keeping context across iterations)
15
+ instead of the default fresh-run coder used here.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import shutil
21
+ import tempfile
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Awaitable, Callable
26
+
27
+ from sdk.local_exec import DEFAULT_ALLOWED_TOOLS, run_claude_local
28
+
29
+ from .bootstrap import ensure_agent_browser
30
+ from .browser import BrowserError, diff_baseline, render_screenshot
31
+ from .compare import ComparisonReport, compare_design
32
+
33
+ # A coder takes (project_path, correction_prompt) and applies fixes in place.
34
+ Coder = Callable[[str, str], Awaitable[None]]
35
+
36
+
37
+ @dataclass
38
+ class Iteration:
39
+ index: int
40
+ screenshot: str
41
+ fidelity_score: int
42
+ discrepancy_count: int
43
+
44
+
45
+ @dataclass
46
+ class RefinementResult:
47
+ passed: bool
48
+ iterations: list[Iteration] = field(default_factory=list)
49
+ final_report: ComparisonReport | None = None
50
+
51
+ @property
52
+ def best_score(self) -> int:
53
+ return max((it.fidelity_score for it in self.iterations), default=0)
54
+
55
+
56
+ async def _default_coder(project_path: str, correction_prompt: str) -> None:
57
+ """Fallback coder: a fresh local `claude` run that applies the corrections.
58
+
59
+ The real pipeline should pass a ``coder`` that RESUMES the ticket's session
60
+ (``resume_session_id=...``) so the agent keeps the design context across
61
+ iterations instead of starting cold each time.
62
+ """
63
+ prompt = (
64
+ "A visual review compared the current frontend against the target design "
65
+ "and found the differences below. Edit the code in this repository to "
66
+ "close them. Make the minimal, correct changes; do not restructure "
67
+ "unrelated code.\n\n"
68
+ f"{correction_prompt}"
69
+ )
70
+ result = await run_claude_local(
71
+ prompt, project_path, allowed_tools=DEFAULT_ALLOWED_TOOLS
72
+ )
73
+ if not result.get("ok"):
74
+ raise RuntimeError(f"correction run failed: {result.get('error')}")
75
+
76
+
77
+ async def verify_and_refine(
78
+ *,
79
+ project_path: str | Path,
80
+ design_path: str | Path,
81
+ preview_url: str,
82
+ artifact_dir: str | Path | None = None,
83
+ cleanup: bool = True,
84
+ threshold: int = 90,
85
+ max_iterations: int = 3,
86
+ full_page: bool = False,
87
+ coder: Coder | None = None,
88
+ on_event: Callable[[str], None] | None = None,
89
+ ) -> RefinementResult:
90
+ """Iteratively refine the generated UI until it matches ``design_path``.
91
+
92
+ Args:
93
+ project_path: repo whose frontend files get edited on each correction.
94
+ design_path: the original design image (e.g. the JIRA screenshot).
95
+ preview_url: URL where the generated UI is being served (dev server).
96
+ artifact_dir: scratch dir for per-iteration screenshots. **Optional** — if
97
+ omitted, an OS temp dir is created and (by default) deleted
98
+ when the run ends. Screenshots are working artifacts, NOT
99
+ permanent storage.
100
+ cleanup: delete the screenshots/diffs when the run ends (default
101
+ True). Set False only for debugging. Note: when True, the
102
+ screenshot paths in the returned result no longer exist.
103
+ threshold: stop once fidelity_score >= this (0-100).
104
+ max_iterations: hard cap on correction rounds.
105
+ coder: applies corrections; defaults to a fresh local claude run.
106
+ on_event: optional progress sink (receives human-readable lines).
107
+
108
+ Returns a :class:`RefinementResult` with per-iteration scores and the final
109
+ comparison report.
110
+ """
111
+ project_path = str(project_path)
112
+ # Screenshots are ephemeral working files. Default to a private OS temp dir
113
+ # that we remove in the `finally` below — nothing is persisted unless the
114
+ # caller both passes an artifact_dir AND sets cleanup=False.
115
+ if artifact_dir is None:
116
+ art = Path(tempfile.mkdtemp(prefix="agentos_design_verify_"))
117
+ else:
118
+ art = Path(artifact_dir).expanduser().resolve()
119
+ art.mkdir(parents=True, exist_ok=True)
120
+ coder = coder or _default_coder
121
+
122
+ def emit(msg: str) -> None:
123
+ if on_event:
124
+ on_event(msg)
125
+
126
+ result = RefinementResult(passed=False)
127
+ report: ComparisonReport | None = None
128
+
129
+ # try/finally spans everything after the scratch dir exists, so the dir is
130
+ # always removed — even if bootstrap or the first render raises.
131
+ try:
132
+ # Provision agent-browser for THIS user on first use (idempotent, cached).
133
+ # The loop can't render without it, so this is a hard prerequisite — but
134
+ # the bootstrap never raises; we surface a clear, actionable error.
135
+ emit("ensuring agent-browser is installed")
136
+ boot = await asyncio.to_thread(ensure_agent_browser)
137
+ if not boot.ok:
138
+ raise BrowserError(f"agent-browser unavailable: {boot.reason}")
139
+ if boot.installed:
140
+ emit(f"agent-browser provisioned: {boot.reason}")
141
+
142
+ for i in range(1, max_iterations + 1):
143
+ shot = art / f"render_iter_{i}.png"
144
+ emit(f"[iter {i}/{max_iterations}] rendering {preview_url}")
145
+ await render_screenshot(preview_url, shot, full_page=full_page)
146
+
147
+ # Native pixel diff first — a fast, localizing signal + a highlight
148
+ # image the vision step can reason over. Best-effort: design mocks
149
+ # often differ in dimensions (CLI refuses the pixel compare), so never
150
+ # fail the loop on it — fall back to a pure vision comparison.
151
+ diff_path: str | None = None
152
+ diff_note = ""
153
+ try:
154
+ dr = await diff_baseline(
155
+ design_path, out_path=art / f"diff_iter_{i}.png"
156
+ )
157
+ if dr.dimension_mismatch:
158
+ diff_note = (
159
+ "the render and design differ in dimensions, so no pixel "
160
+ "diff was produced."
161
+ )
162
+ elif dr.mismatch_pct is not None:
163
+ diff_path = dr.diff_image
164
+ diff_note = f"the raw pixel mismatch is {dr.mismatch_pct:.1f}%."
165
+ except BrowserError as exc:
166
+ emit(f"[iter {i}/{max_iterations}] pixel diff skipped: {exc}")
167
+
168
+ emit(f"[iter {i}/{max_iterations}] comparing against design")
169
+ report = await compare_design(
170
+ design_path, shot, work_dir=art,
171
+ diff_path=diff_path, diff_note=diff_note,
172
+ )
173
+ result.iterations.append(
174
+ Iteration(
175
+ index=i,
176
+ screenshot=str(shot),
177
+ fidelity_score=report.fidelity_score,
178
+ discrepancy_count=len(report.discrepancies),
179
+ )
180
+ )
181
+ emit(
182
+ f"[iter {i}/{max_iterations}] fidelity={report.fidelity_score} "
183
+ f"discrepancies={len(report.discrepancies)}"
184
+ )
185
+
186
+ if report.fidelity_score >= threshold or not report.correction_prompt:
187
+ result.passed = report.fidelity_score >= threshold
188
+ break
189
+
190
+ if i == max_iterations:
191
+ break # scored but out of correction budget
192
+
193
+ emit(f"[iter {i}/{max_iterations}] applying corrections")
194
+ await coder(project_path, report.correction_prompt)
195
+ # Give a hot-reloading dev server a moment to rebuild before re-shooting.
196
+ time.sleep(2)
197
+ finally:
198
+ # Screenshots/diffs are working artifacts, never permanent storage.
199
+ if cleanup:
200
+ shutil.rmtree(art, ignore_errors=True)
201
+ emit("cleaned up screenshots")
202
+
203
+ result.final_report = report
204
+ return result
@@ -162,6 +162,7 @@ def _build_local_prompt(
162
162
  repo_name: str | None = None,
163
163
  grounded: bool = False,
164
164
  branch_convention: str | None = None,
165
+ design_image_path: str | None = None,
165
166
  ) -> str:
166
167
  """Compose the coding prompt for the local claude CLI from the server plan.
167
168
 
@@ -200,6 +201,21 @@ def _build_local_prompt(
200
201
  "code actually is, trust the code and the operator instructions."
201
202
  )
202
203
  parts.append(scope_line)
204
+ # 0.5 Design reference — when the Jira ticket has a design screenshot it is the
205
+ # authoritative VISUAL spec. Placed near the top so the model attends to it;
206
+ # the local claude agent's Read tool views images natively, so it can code
207
+ # against the real design instead of blind from the text description.
208
+ if design_image_path:
209
+ parts.append(
210
+ "=== Design reference (AUTHORITATIVE visual spec) ===\n"
211
+ f"A design screenshot for this task is attached at: {design_image_path}\n"
212
+ "Use the Read tool to VIEW this image BEFORE writing any UI code, and "
213
+ "match it precisely: layout & structure, spacing & alignment, colors, "
214
+ "typography (family/size/weight), component choices, and iconography. "
215
+ "When the image and the text description conflict, the image wins for "
216
+ "anything visual. Treat visual fidelity to this design as a required "
217
+ "acceptance criterion."
218
+ )
203
219
  # 1. Operator instructions — FIRST, verbatim, authoritative.
204
220
  if human_instructions and human_instructions.strip():
205
221
  parts.append(
@@ -1648,6 +1664,35 @@ def _tool_detail(tool_name: str, inp: dict) -> str:
1648
1664
  return ""
1649
1665
 
1650
1666
 
1667
+ async def _download_design_image(client: "BackendClient", task_id: str) -> str | None:
1668
+ """Best-effort: download the ticket's design screenshot to a temp file.
1669
+
1670
+ The local claude agent Reads this path to code against the real design (not
1671
+ blind from text). Returns the temp path or None (no image / any failure).
1672
+ EPHEMERAL — the caller MUST delete it after the run; it is never persisted.
1673
+ """
1674
+ if not task_id:
1675
+ return None
1676
+ try:
1677
+ img = await client.fetch_design_image(task_id)
1678
+ except Exception: # noqa: BLE001 — visual grounding is best-effort
1679
+ return None
1680
+ if not img:
1681
+ return None
1682
+ data, mime = img
1683
+ m = (mime or "").lower()
1684
+ ext = ".png" if "png" in m else (".jpg" if ("jpeg" in m or "jpg" in m) else ".img")
1685
+ try:
1686
+ import os
1687
+ import tempfile
1688
+ fd, path = tempfile.mkstemp(prefix="agentos_design_", suffix=ext)
1689
+ with os.fdopen(fd, "wb") as f:
1690
+ f.write(data)
1691
+ return path
1692
+ except OSError:
1693
+ return None
1694
+
1695
+
1651
1696
  async def _code_one_repo(
1652
1697
  client: BackendClient,
1653
1698
  *,
@@ -1874,21 +1919,36 @@ async def _code_one_repo(
1874
1919
  # When recon ran, the implementation resumes that session so its grounded plan
1875
1920
  # (and the technical PRD, if generated) stays in context; if a PRD is present the
1876
1921
  # prompt makes it the authoritative spec.
1922
+ # Design-aware generation: if the ticket has a design screenshot, download it
1923
+ # (ephemeral) so the local agent can Read it while coding. Consumed by the
1924
+ # FIRST run below — after that it's in the session context, so recovery/repair
1925
+ # resumes don't need the file. Deleted in `finally` regardless of outcome.
1926
+ design_path = await _download_design_image(client, task_id)
1927
+
1877
1928
  prompt = _build_local_prompt(
1878
1929
  pending["user_input"], pending.get("structured_plan"),
1879
1930
  pending.get("human_instructions"),
1880
1931
  agent_mode=agent_mode, git_managed=bool(branch_info),
1881
1932
  repo_name=name, grounded=bool(recon_session), branch_convention=branch_name,
1933
+ design_image_path=design_path,
1882
1934
  )
1883
1935
 
1884
1936
  # 3b. initial claude run. resume_session_id (rate-limit/context retry) wins;
1885
1937
  # otherwise resume the recon session so implementation builds on the recon
1886
1938
  # exploration instead of starting cold.
1887
- result = await local_exec.run_claude_local(
1888
- prompt, repo_path, on_event=_on_event,
1889
- github_token=gh_token, approval_dir=approval_dir,
1890
- resume_session_id=resume_session_id or recon_session or None,
1891
- )
1939
+ try:
1940
+ result = await local_exec.run_claude_local(
1941
+ prompt, repo_path, on_event=_on_event,
1942
+ github_token=gh_token, approval_dir=approval_dir,
1943
+ resume_session_id=resume_session_id or recon_session or None,
1944
+ )
1945
+ finally:
1946
+ if design_path:
1947
+ import os
1948
+ try:
1949
+ os.remove(design_path)
1950
+ except OSError:
1951
+ pass
1892
1952
 
1893
1953
  # 4. context-window recovery
1894
1954
  import time as _time