open-agents-ai 0.187.571 → 0.187.573

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.
package/dist/index.js CHANGED
@@ -2131,14 +2131,6 @@ var init_shell = __esm({
2131
2131
  const command = args["command"];
2132
2132
  const timeout2 = args["timeout"] ?? this.defaultTimeout;
2133
2133
  const stdinInput = args["stdin"];
2134
- if (command && /cobalt\.tools|api\.cobalt\.tools/i.test(command)) {
2135
- return {
2136
- success: false,
2137
- output: "",
2138
- error: "The cobalt.tools API was SHUT DOWN on Nov 11, 2024 (https://github.com/imputnet/cobalt/discussions/860). Use the built-in `youtube_download` or `transcribe_url` tools instead for YouTube audio/video downloads — they use yt-dlp locally.",
2139
- durationMs: performance.now() - start2
2140
- };
2141
- }
2142
2134
  const result = await this.runCommand(command, timeout2, stdinInput);
2143
2135
  if (result.success === false || result.output && result.output.length < 800) {
2144
2136
  const looksTruncated = /\|\s*(tail|head|sed\s+-n|cut\s+|awk\s+'NR)\b/.test(command);
@@ -3808,7 +3800,7 @@ var init_web_fetch = __esm({
3808
3800
  WebFetchTool = class {
3809
3801
  name = "web_fetch";
3810
3802
  _fetchCache = /* @__PURE__ */ new Map();
3811
- description = "Fetch a single web page and return its text content (HTML stripped to plain text). FASTEST web tool — use this for reading any single URL: documentation, articles, README files, API references, Stack Overflow answers. Limitations: no JavaScript rendering (SPAs/React apps return empty), no link following, no cookies/auth, no structured data extraction. If the page is blank or incomplete, switch to web_crawl with strategy='playwright'. For scraping/extracting structured data (prices, listings, tables), use web_crawl instead. For search engine queries, use web_search instead. For interactive browser sessions (login, form filling, clicking), use browser_action instead.";
3803
+ description = "Fetch a single web page and return its text content (HTML stripped to plain text). FASTEST web tool — use this for reading any single URL: documentation, articles, README files, API references, Stack Overflow answers. Limitations: no JavaScript rendering (SPAs/React apps return empty), no link following, no cookies/auth, no structured data extraction. On timeout, automatically falls back to browser_action (headless Chrome) for slow/heavy pages. If the page is blank or incomplete, switch to web_crawl with strategy='playwright'. For scraping/extracting structured data (prices, listings, tables), use web_crawl instead. For search engine queries, use web_search instead. For interactive browser sessions (login, form filling, clicking), use browser_action instead.";
3812
3804
  parameters = {
3813
3805
  type: "object",
3814
3806
  properties: {
@@ -3877,12 +3869,105 @@ var init_web_fetch = __esm({
3877
3869
  durationMs: performance.now() - start2
3878
3870
  };
3879
3871
  } catch (error) {
3872
+ const errMsg = error instanceof Error ? error.message : String(error);
3873
+ if (/abort|timeout/i.test(errMsg)) {
3874
+ const fallback = await this.#hydraFallback(url, maxLength, start2);
3875
+ if (fallback)
3876
+ return fallback;
3877
+ }
3880
3878
  return {
3881
3879
  success: false,
3882
3880
  output: "",
3883
- error: error instanceof Error ? error.message : String(error),
3881
+ error: errMsg,
3882
+ durationMs: performance.now() - start2
3883
+ };
3884
+ }
3885
+ }
3886
+ /** Fallback: use Hydra Chrome automation service (web-scrape-service on :8130)
3887
+ * when the HTTP fetch times out. Navigates with a real headless browser
3888
+ * so slow/heavy/JS pages render fully. Returns null if the service is
3889
+ * unavailable or any step fails — the original timeout error propagates. */
3890
+ async #hydraFallback(url, maxLength, start2) {
3891
+ const BASE = "http://localhost:8130";
3892
+ try {
3893
+ const health = await fetch(`${BASE}/health`, {
3894
+ signal: AbortSignal.timeout(2e3)
3895
+ });
3896
+ if (!health.ok)
3897
+ return null;
3898
+ } catch {
3899
+ return null;
3900
+ }
3901
+ try {
3902
+ const sessionRes = await fetch(`${BASE}/session/start`, {
3903
+ method: "POST",
3904
+ headers: { "Content-Type": "application/json" },
3905
+ body: JSON.stringify({ headless: true }),
3906
+ signal: AbortSignal.timeout(1e4)
3907
+ });
3908
+ const sessionData = await sessionRes.json();
3909
+ if (!sessionData.ok || !sessionData.session_id)
3910
+ return null;
3911
+ const sid = sessionData.session_id;
3912
+ const navRes = await fetch(`${BASE}/navigate`, {
3913
+ method: "POST",
3914
+ headers: { "Content-Type": "application/json" },
3915
+ body: JSON.stringify({ sid, url }),
3916
+ signal: AbortSignal.timeout(6e4)
3917
+ });
3918
+ const navData = await navRes.json();
3919
+ if (!navData.ok) {
3920
+ fetch(`${BASE}/session/close`, {
3921
+ method: "POST",
3922
+ headers: { "Content-Type": "application/json" },
3923
+ body: JSON.stringify({ sid })
3924
+ }).catch(() => {
3925
+ });
3926
+ return null;
3927
+ }
3928
+ const domRes = await fetch(`${BASE}/dom?sid=${encodeURIComponent(sid)}`, {
3929
+ signal: AbortSignal.timeout(3e4)
3930
+ });
3931
+ const domData = await domRes.json();
3932
+ if (!domData.ok) {
3933
+ fetch(`${BASE}/session/close`, {
3934
+ method: "POST",
3935
+ headers: { "Content-Type": "application/json" },
3936
+ body: JSON.stringify({ sid })
3937
+ }).catch(() => {
3938
+ });
3939
+ return null;
3940
+ }
3941
+ const dom = domData.dom;
3942
+ if (!dom || dom.length < 50) {
3943
+ fetch(`${BASE}/session/close`, {
3944
+ method: "POST",
3945
+ headers: { "Content-Type": "application/json" },
3946
+ body: JSON.stringify({ sid })
3947
+ }).catch(() => {
3948
+ });
3949
+ return null;
3950
+ }
3951
+ fetch(`${BASE}/session/close`, {
3952
+ method: "POST",
3953
+ headers: { "Content-Type": "application/json" },
3954
+ body: JSON.stringify({ sid })
3955
+ }).catch(() => {
3956
+ });
3957
+ const text = this.#stripHtml(dom);
3958
+ this._fetchCache.set(url, { text, fetchedAt: Date.now() });
3959
+ const truncated = text.length > maxLength;
3960
+ return {
3961
+ success: true,
3962
+ output: `[Hydra fallback: HTTP fetch timed out, retrieved via Chrome browser]
3963
+
3964
+ ` + (truncated ? `${text.slice(0, maxLength)}
3965
+
3966
+ [Content truncated to ${maxLength} characters]` : text),
3884
3967
  durationMs: performance.now() - start2
3885
3968
  };
3969
+ } catch {
3970
+ return null;
3886
3971
  }
3887
3972
  }
3888
3973
  #stripHtml(html) {
@@ -16777,27 +16862,44 @@ function isYouTubeUrl(url) {
16777
16862
  return /(?:youtube\.com\/(?:watch|shorts|live|embed|v\/)|youtu\.be\/)/i.test(url);
16778
16863
  }
16779
16864
  function ensureYtDlp() {
16780
- try {
16781
- execSync13("yt-dlp --version", { timeout: 5e3, stdio: "pipe" });
16782
- return true;
16783
- } catch {
16865
+ if (_ytDlpPath)
16866
+ return _ytDlpPath;
16867
+ const isWin2 = process.platform === "win32";
16868
+ const venvDir = join27(homedir8(), ".open-agents", "venv");
16869
+ const pipPath = isWin2 ? join27(venvDir, "Scripts", "pip.exe") : join27(venvDir, "bin", "pip");
16870
+ const ytDlpPath = isWin2 ? join27(venvDir, "Scripts", "yt-dlp.exe") : join27(venvDir, "bin", "yt-dlp");
16871
+ if (!existsSync22(pipPath)) {
16784
16872
  try {
16785
- execSync13("pip3 install --user yt-dlp 2>/dev/null || pip install --user yt-dlp 2>/dev/null", {
16786
- timeout: 6e4,
16873
+ mkdirSync9(join27(homedir8(), ".open-agents"), { recursive: true });
16874
+ execSync13(`python3 -m venv "${venvDir}"`, {
16875
+ timeout: 3e4,
16787
16876
  stdio: "pipe"
16788
16877
  });
16789
- return true;
16790
16878
  } catch {
16791
- return false;
16879
+ return null;
16792
16880
  }
16793
16881
  }
16882
+ try {
16883
+ execSync13(`"${pipPath}" install -U yt-dlp 2>&1`, {
16884
+ timeout: 6e4,
16885
+ stdio: "pipe"
16886
+ });
16887
+ _ytDlpPath = ytDlpPath;
16888
+ return ytDlpPath;
16889
+ } catch {
16890
+ if (existsSync22(ytDlpPath)) {
16891
+ _ytDlpPath = ytDlpPath;
16892
+ return ytDlpPath;
16893
+ }
16894
+ return null;
16895
+ }
16794
16896
  }
16795
16897
  function formatTime(seconds) {
16796
16898
  const m2 = Math.floor(seconds / 60);
16797
16899
  const s2 = Math.floor(seconds % 60);
16798
16900
  return `${String(m2).padStart(2, "0")}:${String(s2).padStart(2, "0")}`;
16799
16901
  }
16800
- var AUDIO_EXTS, VIDEO_EXTS, _tcModule, _tcChecked, TranscribeFileTool, TranscribeUrlTool, YouTubeDownloadTool;
16902
+ var AUDIO_EXTS, VIDEO_EXTS, _tcModule, _tcChecked, TranscribeFileTool, _ytDlpPath, TranscribeUrlTool, YouTubeDownloadTool;
16801
16903
  var init_transcribe_tool = __esm({
16802
16904
  "packages/execution/dist/tools/transcribe-tool.js"() {
16803
16905
  "use strict";
@@ -16994,9 +17096,10 @@ var init_transcribe_tool = __esm({
16994
17096
  }
16995
17097
  }
16996
17098
  };
17099
+ _ytDlpPath = null;
16997
17100
  TranscribeUrlTool = class {
16998
17101
  name = "transcribe_url";
16999
- description = "Download and transcribe audio/video from a URL. Supports YouTube links (youtube.com/watch?v=..., youtu.be/...) and direct media URLs (MP3, WAV, MP4, etc.). YouTube audio is extracted via yt-dlp (auto-installed). Transcription is local via faster-whisper (no cloud API).";
17102
+ description = "Download and transcribe audio/video from a URL. Supports YouTube links (youtube.com/watch?v=..., youtu.be/...) and direct media URLs (MP3, WAV, MP4, etc.). YouTube audio is extracted via yt-dlp (shared venv at ~/.open-agents/venv/). If yt-dlp gets YouTube 403 errors, the tool auto-upgrades it. Transcription is local via faster-whisper (no cloud API).";
17000
17103
  parameters = {
17001
17104
  type: "object",
17002
17105
  properties: {
@@ -17034,17 +17137,18 @@ var init_transcribe_tool = __esm({
17034
17137
  let tmpFile = "";
17035
17138
  try {
17036
17139
  if (isYouTubeUrl(url)) {
17037
- if (!ensureYtDlp()) {
17140
+ const ytDlp = ensureYtDlp();
17141
+ if (!ytDlp) {
17038
17142
  return {
17039
17143
  success: false,
17040
17144
  output: "",
17041
- error: "yt-dlp not found and auto-install failed. Install manually: pip3 install yt-dlp",
17145
+ error: "yt-dlp not available via shared venv. Run: python3 -m venv ~/.open-agents/venv && ~/.open-agents/venv/bin/pip install yt-dlp",
17042
17146
  durationMs: performance.now() - start2
17043
17147
  };
17044
17148
  }
17045
17149
  tmpFile = `${tmpBase}.mp3`;
17046
17150
  try {
17047
- execSync13(`yt-dlp -x --audio-format mp3 --audio-quality 5 -o "${tmpBase}.%(ext)s" "${url}" 2>&1`, { timeout: 3e5, stdio: ["pipe", "pipe", "pipe"] });
17151
+ execSync13(`"${ytDlp}" -x --audio-format mp3 --audio-quality 5 -o "${tmpBase}.%(ext)s" "${url}" 2>&1`, { timeout: 3e5, stdio: ["pipe", "pipe", "pipe"] });
17048
17152
  if (!existsSync22(tmpFile)) {
17049
17153
  const { readdirSync: rd } = __require("node:fs");
17050
17154
  const files = rd(tmpDir).filter((f2) => f2.startsWith(`download-`) && f2 !== ".gitkeep");
@@ -17054,10 +17158,11 @@ var init_transcribe_tool = __esm({
17054
17158
  }
17055
17159
  } catch (dlErr) {
17056
17160
  const errMsg = dlErr instanceof Error ? dlErr.message : String(dlErr);
17161
+ const upgradeHint = errMsg.includes("403") ? " YouTube 403 error — yt-dlp was auto-upgraded. Retry; if the issue persists, the video may be region-restricted." : " Is the video available and not age-restricted?";
17057
17162
  return {
17058
17163
  success: false,
17059
17164
  output: "",
17060
- error: `yt-dlp failed: ${errMsg.slice(0, 200)}. Is the video available and not age-restricted?`,
17165
+ error: `yt-dlp failed: ${errMsg.slice(0, 200)}.${upgradeHint}`,
17061
17166
  durationMs: performance.now() - start2
17062
17167
  };
17063
17168
  }
@@ -17117,7 +17222,7 @@ ${result.output}`,
17117
17222
  };
17118
17223
  YouTubeDownloadTool = class {
17119
17224
  name = "youtube_download";
17120
- description = "Download video or audio from YouTube. Saves mp4 (video) or mp3 (audio) to the working directory. Uses yt-dlp (auto-installed). Supports youtube.com/watch, youtu.be, shorts, live URLs.";
17225
+ description = "Download video or audio from YouTube. Saves mp4 (video), mp3, or wav (audio) to the working directory. Uses yt-dlp (auto-upgraded to fix YouTube 403 errors) and ffmpeg internally for audio conversion. If you get YouTube 403 errors, the tool auto-upgrades yt-dlp. For ffmpeg-based processing (cutting, segmenting, concatenating), download wav format which is raw PCM suitable for shell ffmpeg pipelines. Supports youtube.com/watch, youtu.be, shorts, live URLs.";
17121
17226
  parameters = {
17122
17227
  type: "object",
17123
17228
  properties: {
@@ -17127,8 +17232,8 @@ ${result.output}`,
17127
17232
  },
17128
17233
  format: {
17129
17234
  type: "string",
17130
- enum: ["mp3", "mp4"],
17131
- description: "Output format: 'mp3' for audio only, 'mp4' for video (default: mp3)"
17235
+ enum: ["mp3", "mp4", "wav"],
17236
+ description: "Output format: 'mp3' (compressed audio), 'wav' (raw PCM — use for ffmpeg segmentation), 'mp4' (video). Default: mp3"
17132
17237
  },
17133
17238
  output_dir: {
17134
17239
  type: "string",
@@ -17147,25 +17252,44 @@ ${result.output}`,
17147
17252
  const format3 = String(args.format ?? "mp3").toLowerCase();
17148
17253
  const outputDir = String(args.output_dir ?? this.workingDir);
17149
17254
  if (!url) {
17150
- return { success: false, output: "", error: "URL is required", durationMs: Date.now() - start2 };
17255
+ return {
17256
+ success: false,
17257
+ output: "",
17258
+ error: "URL is required",
17259
+ durationMs: Date.now() - start2
17260
+ };
17151
17261
  }
17152
17262
  if (!isYouTubeUrl(url)) {
17153
- return { success: false, output: "", error: "Not a recognized YouTube URL. Supported: youtube.com/watch, youtu.be, shorts, live, embed", durationMs: Date.now() - start2 };
17263
+ return {
17264
+ success: false,
17265
+ output: "",
17266
+ error: "Not a recognized YouTube URL. Supported: youtube.com/watch, youtu.be, shorts, live, embed",
17267
+ durationMs: Date.now() - start2
17268
+ };
17154
17269
  }
17155
- if (!ensureYtDlp()) {
17156
- return { success: false, output: "", error: "yt-dlp not available and auto-install failed. Install manually: pip install yt-dlp", durationMs: Date.now() - start2 };
17270
+ const ytDlp = ensureYtDlp();
17271
+ if (!ytDlp) {
17272
+ return {
17273
+ success: false,
17274
+ output: "",
17275
+ error: "yt-dlp not available via shared venv. Run: python3 -m venv ~/.open-agents/venv && ~/.open-agents/venv/bin/pip install yt-dlp",
17276
+ durationMs: Date.now() - start2
17277
+ };
17157
17278
  }
17158
17279
  mkdirSync9(outputDir, { recursive: true });
17159
17280
  try {
17160
17281
  let title = "download";
17161
17282
  try {
17162
- title = execSync13(`yt-dlp --get-title "${url}"`, { timeout: 15e3, stdio: "pipe" }).toString().trim().replace(/[<>:"/\\|?*]/g, "_").slice(0, 100);
17283
+ title = execSync13(`"${ytDlp}" --get-title "${url}"`, {
17284
+ timeout: 15e3,
17285
+ stdio: "pipe"
17286
+ }).toString().trim().replace(/[<>:"/\\|?*]/g, "_").slice(0, 100);
17163
17287
  } catch {
17164
17288
  }
17165
17289
  if (format3 === "mp4") {
17166
17290
  const outPath = join27(outputDir, `${title}.mp4`);
17167
17291
  const outTemplate = join27(outputDir, `${title}.%(ext)s`);
17168
- execSync13(`yt-dlp -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format mp4 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
17292
+ execSync13(`"${ytDlp}" -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format mp4 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
17169
17293
  const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "mp4");
17170
17294
  return {
17171
17295
  success: true,
@@ -17174,11 +17298,23 @@ Title: ${title}
17174
17298
  Format: mp4`,
17175
17299
  durationMs: Date.now() - start2
17176
17300
  };
17301
+ } else if (format3 === "wav") {
17302
+ const outPath = join27(outputDir, `${title}.wav`);
17303
+ const outTemplate = join27(outputDir, `${title}.%(ext)s`);
17304
+ execSync13(`"${ytDlp}" -x --audio-format wav --audio-quality 0 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
17305
+ const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "wav");
17306
+ return {
17307
+ success: true,
17308
+ output: `Downloaded audio: ${actualPath}
17309
+ Title: ${title}
17310
+ Format: wav`,
17311
+ durationMs: Date.now() - start2
17312
+ };
17177
17313
  } else {
17178
17314
  const outPath = join27(outputDir, `${title}.mp3`);
17179
17315
  const outTemplate = join27(outputDir, `${title}.%(ext)s`);
17180
- execSync13(`yt-dlp -x --audio-format mp3 --audio-quality 0 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
17181
- const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s", "mp3");
17316
+ execSync13(`"${ytDlp}" -x --audio-format mp3 --audio-quality 0 -o "${outTemplate}" "${url}"`, { timeout: 6e5, stdio: "pipe", cwd: outputDir });
17317
+ const actualPath = existsSync22(outPath) ? outPath : outTemplate.replace("%(ext)s)", "mp3");
17182
17318
  return {
17183
17319
  success: true,
17184
17320
  output: `Downloaded audio: ${actualPath}
@@ -617749,6 +617885,9 @@ Rationale: ${proposal.rationale}${provenanceNote}`;
617749
617885
  rl.setPreSubmit(() => statusBar.suggestAccept());
617750
617886
  }
617751
617887
  process.stdout.on("resize", () => {
617888
+ if (statusBar.isActive) {
617889
+ statusBar.reapplyScrollRegion();
617890
+ }
617752
617891
  statusBar.handleResize();
617753
617892
  setTermSize(process.stdout.rows ?? 24, process.stdout.columns ?? 80);
617754
617893
  if (isNeovimActive()) {
@@ -32,7 +32,7 @@ from typing import Dict, Optional
32
32
  # ──────────────────────────────────────────────────────────────
33
33
  # 0) Embedded venv bootstrap (same pattern as other services)
34
34
  # ──────────────────────────────────────────────────────────────
35
- VENV_DIR = Path.cwd() / ".venv"
35
+ VENV_DIR = Path(__file__).resolve().parent / ".venv"
36
36
 
37
37
 
38
38
  def _in_venv() -> bool:
@@ -156,7 +156,12 @@ class Tools:
156
156
  for path in filter(None, candidates):
157
157
  if os.path.isfile(path) and os.access(path, os.X_OK):
158
158
  try:
159
- subprocess.run([path, "--version"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
159
+ subprocess.run(
160
+ [path, "--version"],
161
+ check=True,
162
+ stdout=subprocess.DEVNULL,
163
+ stderr=subprocess.DEVNULL,
164
+ )
160
165
  return path
161
166
  except Exception:
162
167
  continue
@@ -207,8 +212,12 @@ class Tools:
207
212
  snap_drv = "/snap/chromium/current/usr/lib/chromium-browser/chromedriver"
208
213
  if os.path.exists(snap_drv):
209
214
  try:
210
- log_message(f"[open_browser] Using snap chromedriver at {snap_drv}", "DEBUG")
211
- Tools._driver = webdriver.Chrome(service=Service(snap_drv), options=opts)
215
+ log_message(
216
+ f"[open_browser] Using snap chromedriver at {snap_drv}", "DEBUG"
217
+ )
218
+ Tools._driver = webdriver.Chrome(
219
+ service=Service(snap_drv), options=opts
220
+ )
212
221
  log_message("[open_browser] Launched via snap chromedriver.", "SUCCESS")
213
222
  return "Browser launched (snap chromedriver)"
214
223
  except WebDriverException as e:
@@ -217,59 +226,92 @@ class Tools:
217
226
  sys_drv = Tools._find_system_chromedriver()
218
227
  if sys_drv:
219
228
  try:
220
- log_message(f"[open_browser] Trying system chromedriver at {sys_drv}", "DEBUG")
229
+ log_message(
230
+ f"[open_browser] Trying system chromedriver at {sys_drv}", "DEBUG"
231
+ )
221
232
  Tools._driver = webdriver.Chrome(service=Service(sys_drv), options=opts)
222
- log_message("[open_browser] Launched via system chromedriver.", "SUCCESS")
233
+ log_message(
234
+ "[open_browser] Launched via system chromedriver.", "SUCCESS"
235
+ )
223
236
  return "Browser launched (system chromedriver)"
224
237
  except WebDriverException as e:
225
- log_message(f"[open_browser] System chromedriver failed: {e}", "WARNING")
238
+ log_message(
239
+ f"[open_browser] System chromedriver failed: {e}", "WARNING"
240
+ )
226
241
 
227
242
  arch = (platform.machine() or "").lower()
228
243
  if arch in ("aarch64", "arm64", "armv8l", "armv7l") and chrome_bin:
229
244
  try:
230
- raw = subprocess.check_output([chrome_bin, "--version"]).decode().strip()
245
+ raw = (
246
+ subprocess.check_output([chrome_bin, "--version"]).decode().strip()
247
+ )
231
248
  ver = raw.split()[1]
232
249
  url = (
233
250
  f"https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/"
234
251
  f"{ver}/linux-arm64/chromedriver-linux-arm64.zip"
235
252
  )
236
253
  tmp_zip = "/tmp/chromedriver_arm64.zip"
237
- log_message(f"[open_browser] Downloading ARM64 driver from {url}", "DEBUG")
254
+ log_message(
255
+ f"[open_browser] Downloading ARM64 driver from {url}", "DEBUG"
256
+ )
238
257
  subprocess.check_call(["wget", "-qO", tmp_zip, url])
239
258
  subprocess.check_call(["unzip", "-o", tmp_zip, "-d", "/tmp"])
240
- subprocess.check_call(["sudo", "mv", "/tmp/chromedriver", "/usr/local/bin/chromedriver"])
241
- subprocess.check_call(["sudo", "chmod", "+x", "/usr/local/bin/chromedriver"])
259
+ subprocess.check_call(
260
+ ["sudo", "mv", "/tmp/chromedriver", "/usr/local/bin/chromedriver"]
261
+ )
262
+ subprocess.check_call(
263
+ ["sudo", "chmod", "+x", "/usr/local/bin/chromedriver"]
264
+ )
242
265
  drv = shutil.which("chromedriver")
243
266
  log_message(f"[open_browser] Installed ARM64 driver at {drv}", "DEBUG")
244
267
  Tools._driver = webdriver.Chrome(service=Service(drv), options=opts)
245
- log_message("[open_browser] Launched via downloaded ARM64 chromedriver.", "SUCCESS")
268
+ log_message(
269
+ "[open_browser] Launched via downloaded ARM64 chromedriver.",
270
+ "SUCCESS",
271
+ )
246
272
  return "Browser launched (downloaded ARM64 chromedriver)"
247
273
  except Exception as e:
248
- log_message(f"[open_browser] ARM64 download/install failed: {e}", "WARNING")
274
+ log_message(
275
+ f"[open_browser] ARM64 download/install failed: {e}", "WARNING"
276
+ )
249
277
 
250
278
  if arch in ("x86_64", "amd64") and chrome_bin:
251
279
  try:
252
- raw = subprocess.check_output([chrome_bin, "--version"]).decode().strip()
280
+ raw = (
281
+ subprocess.check_output([chrome_bin, "--version"]).decode().strip()
282
+ )
253
283
  browser_major = raw.split()[1].split(".")[0]
254
284
  except Exception:
255
285
  browser_major = "latest"
256
286
  try:
257
- log_message(f"[open_browser] Installing ChromeDriver {browser_major} via webdriver-manager", "DEBUG")
287
+ log_message(
288
+ f"[open_browser] Installing ChromeDriver {browser_major} via webdriver-manager",
289
+ "DEBUG",
290
+ )
258
291
  drv_path = ChromeDriverManager(driver_version=browser_major).install()
259
- Tools._driver = webdriver.Chrome(service=Service(drv_path), options=opts)
292
+ Tools._driver = webdriver.Chrome(
293
+ service=Service(drv_path), options=opts
294
+ )
260
295
  log_message("[open_browser] Launched via webdriver-manager.", "SUCCESS")
261
296
  return "Browser launched (webdriver-manager)"
262
297
  except Exception as e:
263
298
  log_message(f"[open_browser] webdriver-manager failed: {e}", "ERROR")
264
299
 
265
300
  try:
266
- log_message("[open_browser] Attempting `sudo snap install chromium`…", "DEBUG")
301
+ log_message(
302
+ "[open_browser] Attempting `sudo snap install chromium`…", "DEBUG"
303
+ )
267
304
  subprocess.check_call(["sudo", "snap", "install", "chromium"])
268
305
  Tools._driver = webdriver.Chrome(service=Service(snap_drv), options=opts)
269
- log_message("[open_browser] Launched via newly-installed snap chromium.", "SUCCESS")
306
+ log_message(
307
+ "[open_browser] Launched via newly-installed snap chromium.", "SUCCESS"
308
+ )
270
309
  return "Browser launched (snap install fallback)"
271
310
  except Exception as e:
272
- log_message(f"[open_browser] Auto-snap install failed or Chrome still not found: {e}", "ERROR")
311
+ log_message(
312
+ f"[open_browser] Auto-snap install failed or Chrome still not found: {e}",
313
+ "ERROR",
314
+ )
273
315
 
274
316
  raise RuntimeError(
275
317
  "No usable Chrome/Chromium driver. Install Chrome and a matching chromedriver, "
@@ -306,10 +348,14 @@ class Tools:
306
348
  return "Error: browser not open"
307
349
  try:
308
350
  drv = Tools._driver
309
- el = WebDriverWait(drv, timeout).until(EC.element_to_be_clickable((By.CSS_SELECTOR, selector)))
351
+ el = WebDriverWait(drv, timeout).until(
352
+ EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
353
+ )
310
354
  drv.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
311
355
  el.click()
312
- focused = drv.execute_script("return document.activeElement === arguments[0];", el)
356
+ focused = drv.execute_script(
357
+ "return document.activeElement === arguments[0];", el
358
+ )
313
359
  log_message(f"[click] {selector} clicked (focused={focused})", "DEBUG")
314
360
  return f"Clicked {selector}"
315
361
  except Exception as e:
@@ -322,7 +368,9 @@ class Tools:
322
368
  return "Error: browser not open"
323
369
  try:
324
370
  drv = Tools._driver
325
- el = WebDriverWait(drv, timeout).until(EC.element_to_be_clickable((By.CSS_SELECTOR, selector)))
371
+ el = WebDriverWait(drv, timeout).until(
372
+ EC.element_to_be_clickable((By.CSS_SELECTOR, selector))
373
+ )
326
374
  drv.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
327
375
  el.clear()
328
376
  el.send_keys(text + Keys.RETURN)
@@ -337,7 +385,9 @@ class Tools:
337
385
  if not Tools._driver:
338
386
  return ""
339
387
  try:
340
- dom = Tools._driver.execute_script("return document.documentElement.outerHTML;")
388
+ dom = Tools._driver.execute_script(
389
+ "return document.documentElement.outerHTML;"
390
+ )
341
391
  if dom and len(dom) > max_chars:
342
392
  dom = dom[:max_chars]
343
393
  return dom or ""
@@ -437,18 +487,29 @@ class Tools:
437
487
  }
438
488
  return { ok: true, cancelled };
439
489
  """
440
- res = Tools._driver.execute_script(script, float(x), float(y), float(delta_x), float(delta_y))
490
+ res = Tools._driver.execute_script(
491
+ script, float(x), float(y), float(delta_x), float(delta_y)
492
+ )
441
493
  if not isinstance(res, dict) or not res.get("ok"):
442
494
  reason = res.get("reason") if isinstance(res, dict) else "unknown"
443
495
  return f"Error scrolling at point: {reason}"
444
- log_message(f"[scroll_at] wheel dx={delta_x:.2f} dy={delta_y:.2f} at ({x:.1f},{y:.1f})", "DEBUG")
496
+ log_message(
497
+ f"[scroll_at] wheel dx={delta_x:.2f} dy={delta_y:.2f} at ({x:.1f},{y:.1f})",
498
+ "DEBUG",
499
+ )
445
500
  return "Scrolled at point"
446
501
  except Exception as exc:
447
502
  log_message(f"[scroll_at] failed: {exc}", "ERROR")
448
503
  return f"Error scrolling at point: {exc}"
449
504
 
450
505
  @staticmethod
451
- def sync_input(value: str, selector: str = "", submit: bool = False, input_type: str = "", data: Optional[str] = None) -> str:
506
+ def sync_input(
507
+ value: str,
508
+ selector: str = "",
509
+ submit: bool = False,
510
+ input_type: str = "",
511
+ data: Optional[str] = None,
512
+ ) -> str:
452
513
  if not Tools._driver:
453
514
  return "Error: browser not open"
454
515
  try:
@@ -539,7 +600,14 @@ class Tools:
539
600
  };
540
601
  """
541
602
 
542
- res = drv.execute_script(script, selector or '', value or '', bool(submit), input_type or '', data if data is not None else None)
603
+ res = drv.execute_script(
604
+ script,
605
+ selector or "",
606
+ value or "",
607
+ bool(submit),
608
+ input_type or "",
609
+ data if data is not None else None,
610
+ )
543
611
  if not isinstance(res, dict) or not res.get("ok"):
544
612
  reason = res.get("reason") if isinstance(res, dict) else "sync_failed"
545
613
  return f"Error syncing input: {reason}"
@@ -549,6 +617,7 @@ class Tools:
549
617
  log_message(f"[sync_input] failed: {exc}", "ERROR")
550
618
  return f"Error syncing input: {exc}"
551
619
 
620
+
552
621
  # ──────────────────────────────────────────────────────────────
553
622
  # 3) Environment configuration
554
623
  # ──────────────────────────────────────────────────────────────
@@ -562,14 +631,28 @@ MAX_CONCURRENCY = max(4, int(os.getenv("SCRAPE_MAX_CONCURRENCY", "4")))
562
631
  QUEUE_TIMEOUT_S = float(os.getenv("SCRAPE_QUEUE_TIMEOUT_S", "2.0"))
563
632
  RATE_LIMIT_RPS = max(60, int(os.getenv("SCRAPE_RATE_LIMIT_RPS", "60")))
564
633
  RATE_LIMIT_BURST = max(180, int(os.getenv("SCRAPE_RATE_LIMIT_BURST", "180")))
565
- RATE_LIMIT_DISABLED = os.getenv("SCRAPE_RATE_LIMIT_DISABLED", "0").strip().lower() in ("1", "true", "yes", "on")
566
- RATE_LIMIT_LOCAL_BYPASS = os.getenv("SCRAPE_RATE_LIMIT_LOCAL_BYPASS", "1").strip().lower() in ("1", "true", "yes", "on")
634
+ RATE_LIMIT_DISABLED = os.getenv("SCRAPE_RATE_LIMIT_DISABLED", "0").strip().lower() in (
635
+ "1",
636
+ "true",
637
+ "yes",
638
+ "on",
639
+ )
640
+ RATE_LIMIT_LOCAL_BYPASS = os.getenv(
641
+ "SCRAPE_RATE_LIMIT_LOCAL_BYPASS", "1"
642
+ ).strip().lower() in ("1", "true", "yes", "on")
567
643
  RATE_LIMIT_WHITELIST = {
568
- entry.strip() for entry in os.getenv("SCRAPE_RATE_LIMIT_WHITELIST", "").split(",") if entry.strip()
644
+ entry.strip()
645
+ for entry in os.getenv("SCRAPE_RATE_LIMIT_WHITELIST", "").split(",")
646
+ if entry.strip()
569
647
  }
570
648
  FILE_TTL_S = max(60, int(os.getenv("SCRAPE_FILE_TTL_S", "900")))
571
649
  FRAME_KEEPALIVE_S = max(10, int(os.getenv("SCRAPE_FRAME_KEEPALIVE_S", "45")))
572
- HEADLESS_DEFAULT = os.getenv("SCRAPE_HEADLESS_DEFAULT", "1") in ("1", "true", "TRUE", "yes")
650
+ HEADLESS_DEFAULT = os.getenv("SCRAPE_HEADLESS_DEFAULT", "1") in (
651
+ "1",
652
+ "true",
653
+ "TRUE",
654
+ "yes",
655
+ )
573
656
 
574
657
  app = Flask(__name__)
575
658
  CORS(app, resources={r"/*": {"origins": "*"}})
@@ -588,7 +671,9 @@ _RATE_LOCK = threading.Lock()
588
671
  def _slot(timeout: Optional[float] = None):
589
672
  class _Slot:
590
673
  def __init__(self, timeout_val: Optional[float]):
591
- self.timeout = float(QUEUE_TIMEOUT_S if timeout_val is None else timeout_val)
674
+ self.timeout = float(
675
+ QUEUE_TIMEOUT_S if timeout_val is None else timeout_val
676
+ )
592
677
  self.acquired = False
593
678
 
594
679
  def __enter__(self):
@@ -705,7 +790,11 @@ def _now() -> float:
705
790
  def _apply_rate_limit():
706
791
  forwarded = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
707
792
  ip = _sanitize_ip(forwarded or request.remote_addr or "0.0.0.0")
708
- if RATE_LIMIT_DISABLED or (RATE_LIMIT_LOCAL_BYPASS and _is_local_ip(ip)) or ip in RATE_LIMIT_WHITELIST:
793
+ if (
794
+ RATE_LIMIT_DISABLED
795
+ or (RATE_LIMIT_LOCAL_BYPASS and _is_local_ip(ip))
796
+ or ip in RATE_LIMIT_WHITELIST
797
+ ):
709
798
  g.client_ip = ip
710
799
  return
711
800
  now = _now()
@@ -716,9 +805,16 @@ def _apply_rate_limit():
716
805
  _RATE_BUCKETS[ip] = bucket
717
806
  elapsed = max(0.0, now - bucket.get("ts", now))
718
807
  bucket["ts"] = now
719
- tokens = min(float(RATE_LIMIT_BURST), float(bucket.get("tokens", RATE_LIMIT_BURST)) + elapsed * RATE_LIMIT_RPS)
808
+ tokens = min(
809
+ float(RATE_LIMIT_BURST),
810
+ float(bucket.get("tokens", RATE_LIMIT_BURST)) + elapsed * RATE_LIMIT_RPS,
811
+ )
720
812
  if tokens < 1.0:
721
- return jsonify({"ok": False, "error": "rate limit"}), 429, {"Retry-After": "1"}
813
+ return (
814
+ jsonify({"ok": False, "error": "rate limit"}),
815
+ 429,
816
+ {"Retry-After": "1"},
817
+ )
722
818
  bucket["tokens"] = tokens - 1.0
723
819
  g.client_ip = ip
724
820
 
@@ -730,7 +826,11 @@ def _auth_ok(req) -> bool:
730
826
  if API_KEY and header_key and header_key == API_KEY:
731
827
  return True
732
828
  auth = (req.headers.get("Authorization") or "").strip()
733
- if auth.lower().startswith("bearer ") and API_KEY and auth.split(None, 1)[1].strip() == API_KEY:
829
+ if (
830
+ auth.lower().startswith("bearer ")
831
+ and API_KEY
832
+ and auth.split(None, 1)[1].strip() == API_KEY
833
+ ):
734
834
  return True
735
835
  return False
736
836
 
@@ -787,6 +887,7 @@ def _shutdown_cleanup():
787
887
  # Signal handlers: ensure Chrome is killed on SIGTERM/SIGINT
788
888
  import signal as _signal
789
889
 
890
+
790
891
  def _handle_terminate(signum, frame):
791
892
  """Graceful shutdown on SIGTERM/SIGINT — close Chrome then exit."""
792
893
  try:
@@ -796,6 +897,7 @@ def _handle_terminate(signum, frame):
796
897
  _CLEAN_STOP.set()
797
898
  raise SystemExit(0)
798
899
 
900
+
799
901
  _signal.signal(_signal.SIGTERM, _handle_terminate)
800
902
  _signal.signal(_signal.SIGINT, _handle_terminate)
801
903
 
@@ -818,7 +920,13 @@ def _error(message: str, status: int = 400):
818
920
  # ──────────────────────────────────────────────────────────────
819
921
  @app.get("/health")
820
922
  def health():
821
- return jsonify({"status": "ok", "browser_open": Tools.is_browser_open(), "sessions": len(_SESSIONS)})
923
+ return jsonify(
924
+ {
925
+ "status": "ok",
926
+ "browser_open": Tools.is_browser_open(),
927
+ "sessions": len(_SESSIONS),
928
+ }
929
+ )
822
930
 
823
931
 
824
932
  @app.post("/session/start")
@@ -841,7 +949,16 @@ def session_start():
841
949
  "headless": headless,
842
950
  "frames": {},
843
951
  }
844
- _queue_event(sid, {"type": "status", "msg": "browser_started", "detail": msg, "sid": sid, "ts": int(time.time() * 1000)})
952
+ _queue_event(
953
+ sid,
954
+ {
955
+ "type": "status",
956
+ "msg": "browser_started",
957
+ "detail": msg,
958
+ "sid": sid,
959
+ "ts": int(time.time() * 1000),
960
+ },
961
+ )
845
962
  return _ok(session_id=sid, message=msg, headless=headless)
846
963
 
847
964
 
@@ -865,7 +982,10 @@ def navigate():
865
982
  return _error("missing url", 400)
866
983
  with _slot():
867
984
  msg = Tools.navigate(url)
868
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
985
+ _queue_event(
986
+ data.get("sid") or next(iter(_SESSIONS), ""),
987
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
988
+ )
869
989
  if not _result_ok(msg):
870
990
  return _error(msg, 500)
871
991
  return _ok(message=msg)
@@ -883,7 +1003,10 @@ def click_selector():
883
1003
  msg = Tools.click(selector)
884
1004
  if not _result_ok(msg):
885
1005
  return _error(msg, 500)
886
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
1006
+ _queue_event(
1007
+ data.get("sid") or next(iter(_SESSIONS), ""),
1008
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
1009
+ )
887
1010
  return _ok(message=msg)
888
1011
 
889
1012
 
@@ -902,7 +1025,10 @@ def type_text():
902
1025
  msg = Tools.input(selector, str(text))
903
1026
  if not _result_ok(msg):
904
1027
  return _error(msg, 500)
905
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
1028
+ _queue_event(
1029
+ data.get("sid") or next(iter(_SESSIONS), ""),
1030
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
1031
+ )
906
1032
  return _ok(message=msg)
907
1033
 
908
1034
 
@@ -916,7 +1042,10 @@ def scroll():
916
1042
  msg = Tools.scroll(amount)
917
1043
  if not _result_ok(msg):
918
1044
  return _error(msg, 500)
919
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
1045
+ _queue_event(
1046
+ data.get("sid") or next(iter(_SESSIONS), ""),
1047
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
1048
+ )
920
1049
  return _ok(message=msg)
921
1050
 
922
1051
 
@@ -930,7 +1059,10 @@ def scroll_up():
930
1059
  msg = Tools.scroll(-amount)
931
1060
  if not _result_ok(msg):
932
1061
  return _error(msg, 500)
933
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
1062
+ _queue_event(
1063
+ data.get("sid") or next(iter(_SESSIONS), ""),
1064
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
1065
+ )
934
1066
  return _ok(message=msg)
935
1067
 
936
1068
 
@@ -944,7 +1076,10 @@ def scroll_down():
944
1076
  msg = Tools.scroll(amount)
945
1077
  if not _result_ok(msg):
946
1078
  return _error(msg, 500)
947
- _queue_event(data.get("sid") or next(iter(_SESSIONS), ""), {"type": "status", "msg": msg, "ts": int(time.time() * 1000)})
1079
+ _queue_event(
1080
+ data.get("sid") or next(iter(_SESSIONS), ""),
1081
+ {"type": "status", "msg": msg, "ts": int(time.time() * 1000)},
1082
+ )
948
1083
  return _ok(message=msg)
949
1084
 
950
1085
 
@@ -960,8 +1095,12 @@ def scroll_point():
960
1095
  delta_y = float(data.get("deltaY") or data.get("delta_y") or 0.0)
961
1096
  viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
962
1097
  viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
963
- natural_w = float(data.get("naturalW") or data.get("naturalWidth") or viewport_w)
964
- natural_h = float(data.get("naturalH") or data.get("naturalHeight") or viewport_h)
1098
+ natural_w = float(
1099
+ data.get("naturalW") or data.get("naturalWidth") or viewport_w
1100
+ )
1101
+ natural_h = float(
1102
+ data.get("naturalH") or data.get("naturalHeight") or viewport_h
1103
+ )
965
1104
  except Exception:
966
1105
  return _error("invalid scroll coordinates", 400)
967
1106
  if viewport_w <= 0 or viewport_h <= 0:
@@ -972,7 +1111,7 @@ def scroll_point():
972
1111
  vy = y * scale_y
973
1112
  log_message(
974
1113
  f"[scroll_point] ({x:.1f},{y:.1f}) scaled ({vx:.1f},{vy:.1f}) delta ({delta_x:.2f},{delta_y:.2f})",
975
- "DEBUG"
1114
+ "DEBUG",
976
1115
  )
977
1116
  with _slot():
978
1117
  msg = Tools.scroll_point(vx, vy, delta_x, delta_y)
@@ -1029,8 +1168,12 @@ def click_xy():
1029
1168
  y = float(data.get("y"))
1030
1169
  viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
1031
1170
  viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
1032
- natural_w = float(data.get("naturalW") or data.get("naturalWidth") or viewport_w)
1033
- natural_h = float(data.get("naturalH") or data.get("naturalHeight") or viewport_h)
1171
+ natural_w = float(
1172
+ data.get("naturalW") or data.get("naturalWidth") or viewport_w
1173
+ )
1174
+ natural_h = float(
1175
+ data.get("naturalH") or data.get("naturalHeight") or viewport_h
1176
+ )
1034
1177
  except Exception:
1035
1178
  return _error("invalid coordinates", 400)
1036
1179
  if viewport_w <= 0 or viewport_h <= 0:
@@ -1039,7 +1182,10 @@ def click_xy():
1039
1182
  scale_y = natural_h / max(1.0, viewport_h)
1040
1183
  vx = x * scale_x
1041
1184
  vy = y * scale_y
1042
- log_message(f"[click_xy] requested ({x:.1f}, {y:.1f}) → viewport ({vx:.1f},{vy:.1f})", "DEBUG")
1185
+ log_message(
1186
+ f"[click_xy] requested ({x:.1f}, {y:.1f}) → viewport ({vx:.1f},{vy:.1f})",
1187
+ "DEBUG",
1188
+ )
1043
1189
  with _slot():
1044
1190
  try:
1045
1191
  drv = Tools._driver # type: ignore[attr-defined]
@@ -1092,7 +1238,9 @@ def click_xy():
1092
1238
  float(vy),
1093
1239
  )
1094
1240
  if not result or not result.get("ok"):
1095
- return _error(result.get("reason") if isinstance(result, dict) else "click failed", 500)
1241
+ return _error(
1242
+ result.get("reason") if isinstance(result, dict) else "click failed", 500
1243
+ )
1096
1244
  _queue_event(
1097
1245
  data.get("sid") or next(iter(_SESSIONS), ""),
1098
1246
  {
@@ -1120,7 +1268,13 @@ def input_sync():
1120
1268
  data_snippet = data.get("data")
1121
1269
  _touch_session(sid)
1122
1270
  with _slot():
1123
- msg = Tools.sync_input(value, selector=selector, submit=submit, input_type=input_type, data=data_snippet)
1271
+ msg = Tools.sync_input(
1272
+ value,
1273
+ selector=selector,
1274
+ submit=submit,
1275
+ input_type=input_type,
1276
+ data=data_snippet,
1277
+ )
1124
1278
  if not _result_ok(msg):
1125
1279
  return _error(msg, 500)
1126
1280
  return _ok(message=msg)
@@ -1138,8 +1292,12 @@ def drag():
1138
1292
  end_y = float(data.get("endY"))
1139
1293
  viewport_w = float(data.get("viewportW") or data.get("viewport_width"))
1140
1294
  viewport_h = float(data.get("viewportH") or data.get("viewport_height"))
1141
- natural_w = float(data.get("naturalW") or data.get("naturalWidth") or viewport_w)
1142
- natural_h = float(data.get("naturalH") or data.get("naturalHeight") or viewport_h)
1295
+ natural_w = float(
1296
+ data.get("naturalW") or data.get("naturalWidth") or viewport_w
1297
+ )
1298
+ natural_h = float(
1299
+ data.get("naturalH") or data.get("naturalHeight") or viewport_h
1300
+ )
1143
1301
  except Exception:
1144
1302
  return _error("invalid drag coordinates", 400)
1145
1303
  if viewport_w <= 0 or viewport_h <= 0:
@@ -1152,7 +1310,7 @@ def drag():
1152
1310
  end_vy = end_y * scale_y
1153
1311
  log_message(
1154
1312
  f"[drag] ({start_x:.1f},{start_y:.1f})→({end_x:.1f},{end_y:.1f}) viewport ({start_vx:.1f},{start_vy:.1f})→({end_vx:.1f},{end_vy:.1f})",
1155
- "DEBUG"
1313
+ "DEBUG",
1156
1314
  )
1157
1315
  with _slot():
1158
1316
  msg = Tools.drag(start_vx, start_vy, end_vx, end_vy)
@@ -1164,10 +1322,7 @@ def drag():
1164
1322
  {
1165
1323
  "type": "status",
1166
1324
  "msg": msg,
1167
- "detail": {
1168
- "start": [start_vx, start_vy],
1169
- "end": [end_vx, end_vy]
1170
- },
1325
+ "detail": {"start": [start_vx, start_vy], "end": [end_vx, end_vy]},
1171
1326
  "ts": int(time.time() * 1000),
1172
1327
  },
1173
1328
  )
@@ -1182,7 +1337,9 @@ def dom_snapshot():
1182
1337
  if not html:
1183
1338
  return _error("no dom (browser closed?)", 409)
1184
1339
  sid = request.args.get("sid") or next(iter(_SESSIONS), "")
1185
- _queue_event(sid, {"type": "dom", "chars": len(html), "ts": int(time.time() * 1000)})
1340
+ _queue_event(
1341
+ sid, {"type": "dom", "chars": len(html), "ts": int(time.time() * 1000)}
1342
+ )
1186
1343
  return _ok(dom=html, length=len(html))
1187
1344
 
1188
1345
 
@@ -1192,7 +1349,11 @@ def _record_frame_meta(sid: str, fname: str, width: int, height: int) -> None:
1192
1349
  if not meta:
1193
1350
  return
1194
1351
  frames = meta.setdefault("frames", {})
1195
- frames[fname] = {"ts": int(time.time() * 1000), "width": width, "height": height}
1352
+ frames[fname] = {
1353
+ "ts": int(time.time() * 1000),
1354
+ "width": width,
1355
+ "height": height,
1356
+ }
1196
1357
 
1197
1358
 
1198
1359
  @app.get("/screenshot")
@@ -1230,7 +1391,9 @@ def screenshot():
1230
1391
  "ts": int(time.time() * 1000),
1231
1392
  },
1232
1393
  )
1233
- return _ok(file=rel_path, width=width, height=height, mime="image/png", b64=b64_data)
1394
+ return _ok(
1395
+ file=rel_path, width=width, height=height, mime="image/png", b64=b64_data
1396
+ )
1234
1397
 
1235
1398
 
1236
1399
  @app.get("/frames/<path:filename>")
@@ -1286,7 +1449,9 @@ def _unhandled(exc):
1286
1449
  @app.after_request
1287
1450
  def _default_headers(resp):
1288
1451
  resp.headers.setdefault("Cache-Control", "no-store, max-age=0")
1289
- resp.headers.setdefault("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
1452
+ resp.headers.setdefault(
1453
+ "Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"
1454
+ )
1290
1455
  return resp
1291
1456
 
1292
1457
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "open-agents-ai",
3
- "version": "0.187.571",
3
+ "version": "0.187.573",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "open-agents-ai",
9
- "version": "0.187.571",
9
+ "version": "0.187.573",
10
10
  "hasInstallScript": true,
11
11
  "license": "CC-BY-NC-4.0",
12
12
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-agents-ai",
3
- "version": "0.187.571",
3
+ "version": "0.187.573",
4
4
  "description": "AI coding agent powered by open-source models (Ollama/vLLM) — interactive TUI with agentic tool-calling loop",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",