delimit-cli 3.14.28 → 3.14.29

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 (47) hide show
  1. package/gateway/ai/backends/deploy_bridge.py +56 -2
  2. package/gateway/ai/backends/gateway_core.py +212 -1
  3. package/gateway/ai/backends/generate_bridge.py +84 -13
  4. package/gateway/ai/backends/governance_bridge.py +63 -16
  5. package/gateway/ai/backends/memory_bridge.py +77 -76
  6. package/gateway/ai/backends/ops_bridge.py +76 -6
  7. package/gateway/ai/backends/os_bridge.py +23 -3
  8. package/gateway/ai/backends/repo_bridge.py +156 -17
  9. package/gateway/ai/backends/tools_design.py +116 -9
  10. package/gateway/ai/backends/tools_infra.py +200 -72
  11. package/gateway/ai/backends/tools_real.py +8 -0
  12. package/gateway/ai/backends/ui_bridge.py +115 -5
  13. package/gateway/ai/backends/vault_bridge.py +69 -114
  14. package/gateway/ai/content_engine.py +1276 -0
  15. package/gateway/ai/context_fs.py +193 -0
  16. package/gateway/ai/daemon.py +500 -0
  17. package/gateway/ai/data_plane.py +291 -0
  18. package/gateway/ai/deliberation.py +1033 -6
  19. package/gateway/ai/events.py +39 -0
  20. package/gateway/ai/founding_users.py +162 -0
  21. package/gateway/ai/governance.py +698 -4
  22. package/gateway/ai/inbox_daemon.py +78 -17
  23. package/gateway/ai/integrations/__init__.py +1 -0
  24. package/gateway/ai/integrations/opensage_wrapper.py +288 -0
  25. package/gateway/ai/key_resolver.py +95 -0
  26. package/gateway/ai/ledger_manager.py +289 -1
  27. package/gateway/ai/license.py +62 -4
  28. package/gateway/ai/license_core.py +208 -7
  29. package/gateway/ai/local_server.py +215 -0
  30. package/gateway/ai/loop_engine.py +408 -0
  31. package/gateway/ai/mcp_bridge.py +178 -0
  32. package/gateway/ai/release_sync.py +2 -2
  33. package/gateway/ai/screen_record.py +374 -0
  34. package/gateway/ai/secrets_broker.py +235 -0
  35. package/gateway/ai/social.py +189 -27
  36. package/gateway/ai/social_target.py +1635 -0
  37. package/gateway/ai/supabase_sync.py +190 -0
  38. package/gateway/ai/tracing.py +195 -0
  39. package/gateway/core/contract_ledger.py +1 -1
  40. package/gateway/core/dependency_graph.py +1 -1
  41. package/gateway/core/dependency_manifest.py +1 -1
  42. package/gateway/core/diff_engine_v2.py +272 -78
  43. package/gateway/core/event_backbone.py +2 -2
  44. package/gateway/core/event_schema.py +1 -1
  45. package/gateway/core/impact_analyzer.py +1 -1
  46. package/gateway/core/policy_engine.py +4 -0
  47. package/package.json +1 -1
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """Delimit MCP Bridge Relay Server.
3
+
4
+ Bridges HTTP requests to a local MCP server running on stdin/stdout.
5
+ Designed for remote Claude Code sessions to proxy tool calls via SSH tunnel.
6
+
7
+ Usage: python mcp_bridge.py --command "path/to/mcp-server" --port 7824
8
+
9
+ Endpoints:
10
+ GET /health - Health check
11
+ GET /mcp/list - List available tools from the local MCP server
12
+ POST /mcp/call - Call a tool: {"method":"tools/call","params":{"name":"...","arguments":{...}}}
13
+ """
14
+ import argparse, json, logging, select, subprocess, threading
15
+ from http.server import HTTPServer, BaseHTTPRequestHandler
16
+
17
+ logger = logging.getLogger("delimit.mcp_bridge")
18
+
19
+
20
+ class MCPSubprocessClient:
21
+ """Manages a local MCP server subprocess communicating via JSON-RPC over stdio."""
22
+
23
+ def __init__(self, command: str, init_timeout: float = 10.0):
24
+ self.command = command
25
+ self.init_timeout = init_timeout
26
+ self._proc = None
27
+ self._lock = threading.Lock()
28
+ self._request_id = 0
29
+
30
+ def start(self):
31
+ self._proc = subprocess.Popen(
32
+ self.command, shell=True,
33
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
34
+ )
35
+ init_resp = self._send_request("initialize", {
36
+ "protocolVersion": "2024-11-05", "capabilities": {},
37
+ "clientInfo": {"name": "delimit-bridge", "version": "1.0.0"},
38
+ })
39
+ if init_resp is None:
40
+ raise RuntimeError("MCP server did not respond to initialize")
41
+ self._send_notification("notifications/initialized", {})
42
+ logger.info("MCP server initialized: %s", init_resp.get("result", {}).get("serverInfo", {}))
43
+
44
+ def stop(self):
45
+ if self._proc and self._proc.poll() is None:
46
+ self._proc.terminate()
47
+ try:
48
+ self._proc.wait(timeout=5)
49
+ except subprocess.TimeoutExpired:
50
+ self._proc.kill()
51
+
52
+ def list_tools(self) -> dict:
53
+ return self._send_request("tools/list", {})
54
+
55
+ def call_tool(self, name: str, arguments: dict) -> dict:
56
+ return self._send_request("tools/call", {"name": name, "arguments": arguments})
57
+
58
+ def _next_id(self) -> int:
59
+ self._request_id += 1
60
+ return self._request_id
61
+
62
+ def _send_notification(self, method: str, params: dict):
63
+ self._write({"jsonrpc": "2.0", "method": method, "params": params})
64
+
65
+ def _send_request(self, method: str, params: dict, timeout: float = 30.0):
66
+ with self._lock:
67
+ req_id = self._next_id()
68
+ self._write({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
69
+ return self._read(timeout=timeout)
70
+
71
+ def _write(self, msg: dict):
72
+ if self._proc is None or self._proc.stdin is None:
73
+ raise RuntimeError("MCP subprocess not running")
74
+ self._proc.stdin.write((json.dumps(msg) + "\n").encode())
75
+ self._proc.stdin.flush()
76
+
77
+ def _read(self, timeout: float = 30.0):
78
+ if self._proc is None or self._proc.stdout is None:
79
+ return None
80
+ ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
81
+ if not ready:
82
+ logger.warning("Timeout waiting for MCP response")
83
+ return None
84
+ line = self._proc.stdout.readline()
85
+ return json.loads(line.decode().strip()) if line else None
86
+
87
+
88
+ class BridgeHandler(BaseHTTPRequestHandler):
89
+ """HTTP handler that proxies requests to the MCP subprocess."""
90
+ server_version = "DelimitMCPBridge/1.0"
91
+ mcp_client: MCPSubprocessClient = None
92
+
93
+ def log_message(self, fmt, *args):
94
+ logger.info(fmt, *args)
95
+
96
+ def _json(self, data: dict, status: int = 200):
97
+ body = json.dumps(data).encode()
98
+ self.send_response(status)
99
+ self.send_header("Content-Type", "application/json")
100
+ self.send_header("Content-Length", str(len(body)))
101
+ self.end_headers()
102
+ self.wfile.write(body)
103
+
104
+ def _body(self) -> bytes:
105
+ return self.rfile.read(int(self.headers.get("Content-Length", 0)))
106
+
107
+ def _mcp_result(self, resp, on_result=None):
108
+ """Unwrap an MCP JSON-RPC response into an HTTP response."""
109
+ if resp and "result" in resp:
110
+ self._json(on_result(resp["result"]) if on_result else {"result": resp["result"]})
111
+ elif resp and "error" in resp:
112
+ self._json({"error": resp["error"]}, 502)
113
+ else:
114
+ self._json({"error": "no_response_from_mcp_server"}, 502)
115
+
116
+ def do_GET(self):
117
+ if self.path == "/health":
118
+ self._json({"status": "ok", "server": "delimit-mcp-bridge"})
119
+ elif self.path == "/mcp/list":
120
+ try:
121
+ self._mcp_result(self.mcp_client.list_tools(),
122
+ on_result=lambda r: {"tools": r.get("tools", [])})
123
+ except Exception as exc:
124
+ logger.exception("list_tools failed")
125
+ self._json({"error": str(exc)}, 500)
126
+ else:
127
+ self._json({"error": "not_found"}, 404)
128
+
129
+ def do_POST(self):
130
+ if self.path != "/mcp/call":
131
+ self._json({"error": "not_found"}, 404)
132
+ return
133
+ try:
134
+ raw = self._body()
135
+ if not raw:
136
+ self._json({"error": "empty_body"}, 400); return
137
+ body = json.loads(raw)
138
+ except json.JSONDecodeError:
139
+ self._json({"error": "invalid_json"}, 400); return
140
+ params = body.get("params", {})
141
+ tool_name = params.get("name")
142
+ if not tool_name:
143
+ self._json({"error": "missing params.name"}, 400); return
144
+ try:
145
+ self._mcp_result(self.mcp_client.call_tool(tool_name, params.get("arguments", {})))
146
+ except Exception as exc:
147
+ logger.exception("call_tool failed")
148
+ self._json({"error": str(exc)}, 500)
149
+
150
+
151
+ def create_server(command: str, port: int = 7824, init_timeout: float = 10.0):
152
+ """Create and return (httpd, mcp_client) without starting the serve loop."""
153
+ client = MCPSubprocessClient(command, init_timeout=init_timeout)
154
+ client.start()
155
+ handler = type("Handler", (BridgeHandler,), {"mcp_client": client})
156
+ return HTTPServer(("127.0.0.1", port), handler), client
157
+
158
+
159
+ def main():
160
+ parser = argparse.ArgumentParser(description="Delimit MCP Bridge Relay")
161
+ parser.add_argument("--command", required=True, help="Command to spawn the local MCP server")
162
+ parser.add_argument("--port", type=int, default=7824, help="Port to listen on (default 7824)")
163
+ args = parser.parse_args()
164
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
165
+ logger.info("Starting MCP bridge: command=%r port=%d", args.command, args.port)
166
+ httpd, client = create_server(args.command, args.port)
167
+ try:
168
+ logger.info("Bridge listening on 127.0.0.1:%d", args.port)
169
+ httpd.serve_forever()
170
+ except KeyboardInterrupt:
171
+ logger.info("Shutting down")
172
+ finally:
173
+ httpd.server_close()
174
+ client.stop()
175
+
176
+
177
+ if __name__ == "__main__":
178
+ main()
@@ -28,10 +28,10 @@ DEFAULT_CONFIG = {
28
28
  "urls": {
29
29
  "homepage": "https://delimit.ai",
30
30
  "docs": "https://delimit.ai/docs",
31
- "github": "https://github.com/delimit-ai/delimit",
31
+ "github": "https://github.com/delimit-ai/delimit-mcp-server",
32
32
  "action": "https://github.com/marketplace/actions/delimit-api-governance",
33
33
  "npm": "https://www.npmjs.com/package/delimit-cli",
34
- "quickstart": "https://github.com/delimit-ai/delimit-quickstart",
34
+ "quickstart": "https://github.com/delimit-ai/delimit-mcp-server-quickstart",
35
35
  },
36
36
  }
37
37
 
@@ -0,0 +1,374 @@
1
+ """
2
+ Screen recording helpers for Delimit MCP.
3
+
4
+ Two modes:
5
+ - browser: Xvfb + Chromium + ffmpeg -> MP4
6
+ - terminal: asciinema + agg + ffmpeg -> .cast + GIF + MP4
7
+
8
+ Bonus:
9
+ - take_screenshot: Chromium headless screenshot -> PNG
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Optional
20
+
21
+ logger = logging.getLogger("delimit.ai.screen_record")
22
+
23
+ # ── Constants ────────────────────────────────────────────────────────────
24
+
25
+ CHROMIUM_PATH = "/root/.cache/puppeteer/chrome/linux-146.0.7680.153/chrome-linux64/chrome"
26
+ CONTENT_BASE = Path.home() / ".delimit" / "content"
27
+ VIDEOS_DIR = CONTENT_BASE / "videos"
28
+ GIFS_DIR = CONTENT_BASE / "gifs"
29
+ CASTS_DIR = CONTENT_BASE / "casts"
30
+ SCREENSHOTS_DIR = CONTENT_BASE / "screenshots"
31
+
32
+ MAX_DURATION = 120
33
+ DEFAULT_WIDTH = 1920
34
+ DEFAULT_HEIGHT = 1080
35
+ DISPLAY_NUM = 99
36
+
37
+
38
+ def _ensure_dirs():
39
+ """Create output directories if they don't exist."""
40
+ for d in (VIDEOS_DIR, GIFS_DIR, CASTS_DIR, SCREENSHOTS_DIR):
41
+ d.mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ def _check_binary(name: str) -> Optional[str]:
45
+ """Return path to binary or None if not found."""
46
+ return shutil.which(name)
47
+
48
+
49
+ def _file_size_kb(path: Path) -> int:
50
+ """Return file size in KB, or 0 if missing."""
51
+ try:
52
+ return int(path.stat().st_size / 1024)
53
+ except (OSError, FileNotFoundError):
54
+ return 0
55
+
56
+
57
+ def _kill_pid(pid: int):
58
+ """Kill a process, ignoring errors."""
59
+ try:
60
+ os.kill(pid, signal.SIGTERM)
61
+ except (OSError, ProcessLookupError):
62
+ pass
63
+
64
+
65
+ # ── Browser Recording ────────────────────────────────────────────────────
66
+
67
+ def record_browser(url: str, name: str, duration: int) -> Dict[str, Any]:
68
+ """Record a Chromium browser session visiting a URL as 1080p MP4.
69
+
70
+ Starts Xvfb virtual display, ffmpeg screen capture, and Chromium.
71
+ Cleans up all processes on completion or failure.
72
+ """
73
+ # Dependency checks
74
+ missing = []
75
+ if not _check_binary("Xvfb"):
76
+ missing.append("Xvfb (apt install xvfb)")
77
+ if not _check_binary("ffmpeg"):
78
+ missing.append("ffmpeg (apt install ffmpeg)")
79
+ if not Path(CHROMIUM_PATH).exists():
80
+ missing.append(f"Chromium at {CHROMIUM_PATH}")
81
+ if missing:
82
+ return {
83
+ "recorded": False,
84
+ "error": "missing_dependencies",
85
+ "message": f"Required binaries not found: {', '.join(missing)}",
86
+ }
87
+
88
+ if not url:
89
+ return {"recorded": False, "error": "missing_url", "message": "url is required for browser mode"}
90
+
91
+ duration = min(max(1, duration), MAX_DURATION)
92
+ _ensure_dirs()
93
+
94
+ output_path = VIDEOS_DIR / f"{name}.mp4"
95
+ display = f":{DISPLAY_NUM}"
96
+
97
+ xvfb_proc = None
98
+ ffmpeg_proc = None
99
+ chrome_proc = None
100
+
101
+ try:
102
+ # 1. Start virtual display
103
+ xvfb_proc = subprocess.Popen(
104
+ ["Xvfb", display, "-screen", "0", f"{DEFAULT_WIDTH}x{DEFAULT_HEIGHT}x24"],
105
+ stdout=subprocess.DEVNULL,
106
+ stderr=subprocess.DEVNULL,
107
+ )
108
+ time.sleep(1)
109
+
110
+ env = os.environ.copy()
111
+ env["DISPLAY"] = display
112
+
113
+ # 2. Start ffmpeg recording
114
+ ffmpeg_proc = subprocess.Popen(
115
+ [
116
+ "ffmpeg", "-y",
117
+ "-video_size", f"{DEFAULT_WIDTH}x{DEFAULT_HEIGHT}",
118
+ "-framerate", "30",
119
+ "-f", "x11grab", "-i", display,
120
+ "-t", str(duration),
121
+ "-c:v", "libx264", "-preset", "ultrafast", "-crf", "23",
122
+ "-pix_fmt", "yuv420p",
123
+ str(output_path),
124
+ ],
125
+ stdout=subprocess.DEVNULL,
126
+ stderr=subprocess.PIPE,
127
+ env=env,
128
+ )
129
+ time.sleep(1)
130
+
131
+ # 3. Launch Chromium
132
+ chrome_proc = subprocess.Popen(
133
+ [
134
+ CHROMIUM_PATH,
135
+ "--no-sandbox",
136
+ "--disable-gpu",
137
+ "--disable-dev-shm-usage",
138
+ f"--window-size={DEFAULT_WIDTH},{DEFAULT_HEIGHT}",
139
+ "--start-maximized",
140
+ url,
141
+ ],
142
+ stdout=subprocess.DEVNULL,
143
+ stderr=subprocess.DEVNULL,
144
+ env=env,
145
+ )
146
+
147
+ # 4. Wait for ffmpeg to finish (it will stop after -t duration)
148
+ ffmpeg_proc.wait(timeout=duration + 30)
149
+
150
+ return {
151
+ "recorded": True,
152
+ "mode": "browser",
153
+ "files": {
154
+ "mp4": str(output_path),
155
+ },
156
+ "duration": duration,
157
+ "size_kb": _file_size_kb(output_path),
158
+ "url": url,
159
+ }
160
+
161
+ except subprocess.TimeoutExpired:
162
+ if ffmpeg_proc:
163
+ ffmpeg_proc.kill()
164
+ return {
165
+ "recorded": False,
166
+ "error": "timeout",
167
+ "message": f"Recording timed out after {duration + 30}s",
168
+ }
169
+ except Exception as e:
170
+ logger.error("Browser recording failed: %s", e)
171
+ return {
172
+ "recorded": False,
173
+ "error": "recording_failed",
174
+ "message": str(e),
175
+ }
176
+ finally:
177
+ # Always clean up processes
178
+ if chrome_proc and chrome_proc.poll() is None:
179
+ _kill_pid(chrome_proc.pid)
180
+ if ffmpeg_proc and ffmpeg_proc.poll() is None:
181
+ _kill_pid(ffmpeg_proc.pid)
182
+ if xvfb_proc and xvfb_proc.poll() is None:
183
+ _kill_pid(xvfb_proc.pid)
184
+
185
+
186
+ # ── Terminal Recording ───────────────────────────────────────────────────
187
+
188
+ def record_terminal(name: str, duration: int, script: str = "") -> Dict[str, Any]:
189
+ """Record a terminal session as .cast + GIF + MP4.
190
+
191
+ Uses asciinema to record, agg to convert to GIF, ffmpeg for MP4.
192
+ If script is provided, it runs non-interactively via asciinema rec -c.
193
+ """
194
+ missing = []
195
+ if not _check_binary("asciinema"):
196
+ missing.append("asciinema (pip install asciinema)")
197
+ if not _check_binary("agg"):
198
+ missing.append("agg (cargo install agg)")
199
+ if not _check_binary("ffmpeg"):
200
+ missing.append("ffmpeg (apt install ffmpeg)")
201
+ if missing:
202
+ return {
203
+ "recorded": False,
204
+ "error": "missing_dependencies",
205
+ "message": f"Required binaries not found: {', '.join(missing)}",
206
+ }
207
+
208
+ duration = min(max(1, duration), MAX_DURATION)
209
+ _ensure_dirs()
210
+
211
+ cast_path = CASTS_DIR / f"{name}.cast"
212
+ gif_path = GIFS_DIR / f"{name}.gif"
213
+ mp4_path = VIDEOS_DIR / f"{name}.mp4"
214
+
215
+ try:
216
+ # 1. Record with asciinema
217
+ asciinema_cmd = [
218
+ "asciinema", "rec", str(cast_path),
219
+ "--overwrite", "--cols", "120", "--rows", "35",
220
+ ]
221
+
222
+ if script:
223
+ # Run a command non-interactively
224
+ asciinema_cmd.extend(["-c", script])
225
+ result = subprocess.run(
226
+ asciinema_cmd,
227
+ timeout=duration + 10,
228
+ capture_output=True,
229
+ text=True,
230
+ )
231
+ else:
232
+ # Record idle terminal for specified duration
233
+ # Use a simple sleep command to fill the duration
234
+ asciinema_cmd.extend(["-c", f"sleep {duration}"])
235
+ result = subprocess.run(
236
+ asciinema_cmd,
237
+ timeout=duration + 10,
238
+ capture_output=True,
239
+ text=True,
240
+ )
241
+
242
+ if not cast_path.exists():
243
+ return {
244
+ "recorded": False,
245
+ "error": "asciinema_failed",
246
+ "message": f"asciinema did not produce output: {result.stderr}",
247
+ }
248
+
249
+ files = {"cast": str(cast_path)}
250
+
251
+ # 2. Convert to GIF via agg
252
+ agg_result = subprocess.run(
253
+ [
254
+ "agg", str(cast_path), str(gif_path),
255
+ "--font-size", "16",
256
+ "--theme", "monokai",
257
+ "--speed", "1.5",
258
+ "--cols", "120",
259
+ "--rows", "35",
260
+ ],
261
+ timeout=60,
262
+ capture_output=True,
263
+ text=True,
264
+ )
265
+ if gif_path.exists():
266
+ files["gif"] = str(gif_path)
267
+
268
+ # 3. Convert GIF to MP4 via ffmpeg
269
+ if gif_path.exists():
270
+ ffmpeg_result = subprocess.run(
271
+ [
272
+ "ffmpeg", "-y", "-i", str(gif_path),
273
+ "-movflags", "faststart",
274
+ "-pix_fmt", "yuv420p",
275
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
276
+ str(mp4_path),
277
+ ],
278
+ timeout=60,
279
+ capture_output=True,
280
+ text=True,
281
+ )
282
+ if mp4_path.exists():
283
+ files["mp4"] = str(mp4_path)
284
+
285
+ return {
286
+ "recorded": True,
287
+ "mode": "terminal",
288
+ "files": files,
289
+ "duration": duration,
290
+ "size_kb": _file_size_kb(mp4_path) or _file_size_kb(gif_path) or _file_size_kb(cast_path),
291
+ }
292
+
293
+ except subprocess.TimeoutExpired:
294
+ return {
295
+ "recorded": False,
296
+ "error": "timeout",
297
+ "message": f"Terminal recording timed out after {duration + 10}s",
298
+ }
299
+ except Exception as e:
300
+ logger.error("Terminal recording failed: %s", e)
301
+ return {
302
+ "recorded": False,
303
+ "error": "recording_failed",
304
+ "message": str(e),
305
+ }
306
+
307
+
308
+ # ── Screenshot ───────────────────────────────────────────────────────────
309
+
310
+ def take_screenshot(url: str, name: str) -> Dict[str, Any]:
311
+ """Take a single screenshot of a URL using headless Chromium.
312
+
313
+ Returns PNG path. Useful for audit evidence and visual regression.
314
+ """
315
+ if not Path(CHROMIUM_PATH).exists():
316
+ return {
317
+ "recorded": False,
318
+ "error": "missing_dependencies",
319
+ "message": f"Chromium not found at {CHROMIUM_PATH}",
320
+ }
321
+
322
+ if not url:
323
+ return {"recorded": False, "error": "missing_url", "message": "url is required for screenshot"}
324
+
325
+ _ensure_dirs()
326
+ output_path = SCREENSHOTS_DIR / f"{name}.png"
327
+
328
+ try:
329
+ result = subprocess.run(
330
+ [
331
+ CHROMIUM_PATH,
332
+ "--headless",
333
+ "--no-sandbox",
334
+ "--disable-gpu",
335
+ "--disable-dev-shm-usage",
336
+ f"--window-size={DEFAULT_WIDTH},{DEFAULT_HEIGHT}",
337
+ f"--screenshot={output_path}",
338
+ url,
339
+ ],
340
+ timeout=30,
341
+ capture_output=True,
342
+ text=True,
343
+ )
344
+
345
+ if output_path.exists():
346
+ return {
347
+ "recorded": True,
348
+ "mode": "screenshot",
349
+ "files": {
350
+ "png": str(output_path),
351
+ },
352
+ "size_kb": _file_size_kb(output_path),
353
+ "url": url,
354
+ }
355
+ else:
356
+ return {
357
+ "recorded": False,
358
+ "error": "screenshot_failed",
359
+ "message": f"Chromium did not produce screenshot: {result.stderr[:500]}",
360
+ }
361
+
362
+ except subprocess.TimeoutExpired:
363
+ return {
364
+ "recorded": False,
365
+ "error": "timeout",
366
+ "message": "Screenshot timed out after 30s",
367
+ }
368
+ except Exception as e:
369
+ logger.error("Screenshot failed: %s", e)
370
+ return {
371
+ "recorded": False,
372
+ "error": "screenshot_failed",
373
+ "message": str(e),
374
+ }