From 501431bf597602f6c2c05a8b13b8d60d2e7700ed Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 01:20:32 +1000 Subject: [PATCH] Flow rinse: browser downloads, fast-fail, autoStart, launcher + self-healing launchd - background.js: chrome.downloads via page session (no CSP/base64) - content.js: fast-fail on Flow error banner, image vs video timeouts, autoStart - local_server_example.py: /enqueue + /save, downloads/ output, resume-skip - launch.sh: idempotent server-up + caffeinate + single-tab focus - install.sh + plist: self-healing agent every 10 min (TCC-safe App Support copy) - flowclient.py: enqueue/wait_for helper for game projects - README: full setup + macOS TCC gotcha Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +- README.md | 66 ++++++++++++++++++++++++++++ background.js | 25 +++++++---- content.js | 62 +++++++++++++++++++++++---- flowclient.py | 49 +++++++++++++++++++++ games.monsterrobot.flowrinse.plist | 36 ++++++++++++++++ install.sh | 40 +++++++++++++++++ launch.sh | 57 ++++++++++++++++++++++++ local_server_example.py | 69 +++++++++++++++++++++++++----- manifest.json | 3 +- popup.html | 4 ++ popup.js | 4 ++ 12 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 README.md create mode 100644 flowclient.py create mode 100644 games.monsterrobot.flowrinse.plist create mode 100644 install.sh create mode 100755 launch.sh diff --git a/.gitignore b/.gitignore index 3892d88..aae1e22 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ __pycache__/ *.pyc -# local runtime output (harvested URLs) +# local runtime output (harvested URLs + downloaded assets) results.jsonl +downloads/ # saved SPA pages can embed auth tokens / cookies — never commit Google Flow*.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..be4ae88 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Flow Auto-Pilot + +A Brave extension + local queue that drains a prompt bank through Google Labs +**Flow** (Nano Banana 2 images, Veo videos) on the webui-only credits you can't +reach via API. The extension types prompts into the Flow agent box with +**trusted CDP input** (Flow ignores synthetic DOM events), waits for the media +grid to grow, harvests the new asset URLs, and downloads them with the page's +session cookies. + +Not headless: Brave must be running and logged into the Flow account. But once +the tab is open with **Auto-start** ticked, it runs unattended. + +## Parts + +| File | Role | +|---|---| +| `manifest.json`, `background.js`, `content.js`, `popup.*` | the Brave MV3 extension | +| `local_server_example.py` | queue server on `:8017` — `queue.jsonl` in, `results.jsonl` out | +| `queue.jsonl` | the prompt bank (one task per line) | +| `flowclient.py` | `enqueue()` / `wait_for()` so a game project can request assets | +| `launch.sh` | start server + keep Mac awake + open/focus one Flow tab | +| `install.sh` + `games.monsterrobot.flowrinse.plist` | self-healing launchd agent | + +## Run it by hand + +``` +./launch.sh https://labs.google/fx/tools/flow/project/ +``` + +Server comes up (under `caffeinate`), Brave focuses the Flow tab, and with +Auto-start ticked the rinse begins. Files land in +`~/Downloads/flowrinse//`. + +## Run it unattended (launchd) + +``` +./install.sh +``` + +Fires `launch.sh` at login and every 10 min. Because `launch.sh` is idempotent +(port check + tab reuse), the repeat runs are no-ops that self-heal a dead +server or a closed tab. + +**macOS TCC gotcha** (same as the vault-backup job): launchd can't execute +scripts inside `~/Documents`, so `install.sh` runs a **copy** at +`~/Library/Application Support/flowrinse/`. That copy owns the live +`queue.jsonl` / `results.jsonl` / `downloads/` once the agent is running. +Re-run `./install.sh` after editing `launch.sh` or `local_server_example.py` +to resync the copy. First run pops one Automation prompt (allow control of +Brave) — click Allow on the rig's screen. + +``` +launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now +launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it +``` + +## Enqueue from another project + +```python +from flowclient import enqueue +enqueue("Sierra EGA record-store back room, 160x168, 16-color", + kind="image", category="morp2_bg") +``` + +Or over the wire: `POST http://localhost:8017/enqueue` with +`{"prompt","kind","model","category","dest"}`. diff --git a/background.js b/background.js index 9138b61..a9282fd 100644 --- a/background.js +++ b/background.js @@ -1,8 +1,9 @@ -// Background service worker: performs TRUSTED input via the Chrome DevTools Protocol. -// Flow's agent editor ignores synthetic DOM events (isTrusted=false) — the text shows -// but the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted, -// so the framework actually registers them. Requires the "debugger" permission; the user -// sees a "started debugging this browser" banner while it runs (expected). +// Background service worker. +// * Trusted input via the Chrome DevTools Protocol — Flow's editor ignores synthetic +// DOM events (isTrusted=false); CDP Input.* events are trusted so it registers them. +// * Asset download — fetches the harvested media URL with the page's session cookies +// (host_permission covers labs.google) and hands the bytes to the local server. Runs +// in the worker, so it's not blocked by the page's CSP. const attached = new Set(); @@ -20,7 +21,6 @@ async function ensure(tabId) { if (attached.has(tabId)) return; try { await attach(tabId); attached.add(tabId); } catch (e) { - // "Another debugger is already attached" => DevTools open on the tab; treat as attached-ish if (/already attached/i.test(String(e.message || e))) attached.add(tabId); else throw e; } @@ -32,15 +32,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const tabId = sender.tab && sender.tab.id; (async () => { try { - if (!tabId) throw new Error('no tab id'); - await ensure(tabId); if (msg.type === 'insertText') { + await ensure(tabId); await cmd(tabId, 'Input.insertText', { text: msg.text }); sendResponse({ ok: true }); } else if (msg.type === 'pressEnter') { + await ensure(tabId); await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER }); await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER }); sendResponse({ ok: true }); + } else if (msg.type === 'download') { + // Browser-native download: uses the page's session cookies, follows the media + // redirect, no CSP/CORS/base64. Lands under the browser's Downloads dir. + const dlId = await new Promise((res, rej) => chrome.downloads.download( + { url: msg.url, filename: msg.filename, conflictAction: 'uniquify', saveAs: false }, + (id) => chrome.runtime.lastError ? rej(chrome.runtime.lastError) : res(id))); + sendResponse({ ok: dlId != null, id: dlId }); } else if (msg.type === 'detach') { if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); } sendResponse({ ok: true }); @@ -51,7 +58,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: false, error: String(e.message || e) }); } })(); - return true; // keep the message channel open for the async response + return true; // async response }); chrome.debugger.onDetach.addListener((src) => { if (src.tabId) attached.delete(src.tabId); }); diff --git a/content.js b/content.js index 0125f78..344246e 100644 --- a/content.js +++ b/content.js @@ -15,6 +15,7 @@ let settings = { serverUrl: 'http://localhost:8017', isRunning: false, + autoStart: false, // begin polling the moment the Flow tab loads (for scripted launch) // Optional CSS overrides — leave blank to use the resilient generic finders. promptSel: '', submitSel: '', @@ -29,6 +30,10 @@ let generationInProgress = false; function init() { chrome.storage.local.get(settings, (data) => { settings = { ...settings, ...data }; + if (settings.autoStart && !settings.isRunning) { + settings.isRunning = true; + chrome.storage.local.set({ isRunning: true }); // so the popup reflects it + } log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`); if (settings.isRunning) startPolling(); }); @@ -165,14 +170,40 @@ function clickConfirmIfPresent() { function snapshotAssets() { const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document; const urls = new Set(); - const push = (u) => { if (u && /^https?:/.test(u) && !/^data:/.test(u)) urls.add(u.split('#')[0]); }; + const ok = (u) => u && !/^data:/.test(u) && (/^https?:/.test(u) || /^blob:/.test(u)); + const push = (u) => { if (ok(u)) urls.add(u.split('#')[0]); }; scope.querySelectorAll('video[src]').forEach(v => push(v.src)); scope.querySelectorAll('video source[src]').forEach(s => push(s.src)); - scope.querySelectorAll('a[href*="googleusercontent"], a[download][href]').forEach(a => push(a.href)); - scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.src); }); + scope.querySelectorAll('video[poster]').forEach(v => push(v.poster)); + scope.querySelectorAll('a[href]').forEach(a => { + if (a.hasAttribute('download') || /googleusercontent|media\.getMediaUrl|\/media\//i.test(a.href)) push(a.href); + }); + // images: match media hosts + the redirect API + blobs (diffed before/after, so only NEW ones count) + scope.querySelectorAll('img[src]').forEach(i => { + if (/googleusercontent|lh3|ggpht|storage|media\.getMediaUrl|^blob:/i.test(i.src)) push(i.src); + }); return urls; } +// Detect Flow's own failure banner so we can skip fast instead of waiting the full timeout. +function flowError() { + return [...document.querySelectorAll('*')].some(e => visible(e) && + /something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/i.test(e.textContent || '') && + (e.textContent || '').length < 90); +} + +// Poll for: a new asset (win), a Flow error banner (fast-fail), or timeout. +// The grace period avoids catching a stale error banner from the previous turn. +async function waitForResultOrError(baseCount, maxMs) { + const t0 = Date.now(); + while (Date.now() - t0 < maxMs) { + if (snapshotAssets().size > baseCount) return 'asset'; + if (Date.now() - t0 > 8000 && flowError()) return 'error'; + await sleep(2500); + } + return null; +} + // ---- queue loop ---------------------------------------------------------- function startPolling() { @@ -217,18 +248,31 @@ async function executeTask(task) { await sleep(1500); log('info', 'Submitted. Waiting for generation…'); - // Wait until a NEW asset appears AND the asset count stops growing. - const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500); - if (!appeared) { - // last-chance: a quota/credit wall? - const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120); - throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.'); + // Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick. + const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs; + const outcome = await waitForResultOrError(before.size, timeoutMs); + if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") — skipping.'); + if (outcome !== 'asset') { + const wall = [...document.querySelectorAll('*')].some(e => visible(e) && + /out of credits|no credits|insufficient|quota exceeded|run out of|daily limit|reached your limit/i.test(e.textContent || '') && + (e.textContent || '').length < 160); + throw new Error(wall ? 'Real credit/quota wall detected.' : 'Timed out — no new asset (harvest miss or Flow queue).'); } await waitForStable(() => snapshotAssets().size, { min: before.size + 1 }); const after = snapshotAssets(); const fresh = [...after].filter(u => !before.has(u)); log('success', `Harvested ${fresh.length} asset(s).`); + + // Browser download — uses your session, no CSP/CORS. Lands in Downloads/flowrinse//. + const ext = task.kind === 'video' ? 'mp4' : 'png'; + const cat = (task.category || 'misc').replace(/[^a-z0-9_\-]/gi, '_'); + let saved = 0; + for (let i = 0; i < fresh.length; i++) { + const r = await bg({ type: 'download', url: fresh[i], filename: `flowrinse/${cat}/${task.id}_${i}.${ext}` }); + if (r && r.ok) saved++; + } + if (fresh.length) log(saved === fresh.length ? 'success' : 'info', `Downloaded ${saved}/${fresh.length} → Downloads/flowrinse/${cat}/`); await reportCompletion(task.id, 'success', null, fresh); } catch (err) { log('error', `Task ${task.id} failed: ${err.message}`); diff --git a/flowclient.py b/flowclient.py new file mode 100644 index 0000000..1b2c723 --- /dev/null +++ b/flowclient.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Tiny client so OpShopGame / 90sDJsim can REQUEST Flow assets and RETRIEVE them. + +The running Flow extension is the bridge to Google — the games never touch it. + + from flowclient import enqueue, wait_for + + r = enqueue("Low-poly vinyl crate icon, transparent bg, 90s UI", + kind="image", category="djsim_ui", + dest="/Users/johnking/Documents/90sDJsim/assets/ui") + path = wait_for(r["dest"], r["id"]) # blocks until the file lands + +Request -> appended to the queue; the extension drains it, generates, downloads. +Retrieve -> the file appears in (and in flowext/downloads//). +""" +import json, os, sys, time, urllib.request + +SERVER = os.environ.get("FLOW_SERVER", "http://localhost:8017") + + +def enqueue(prompt, kind="image", model=None, category="request", dest=None, server=SERVER): + body = json.dumps({"prompt": prompt, "kind": kind, "model": model, + "category": category, "dest": dest}).encode() + req = urllib.request.Request(server + "/enqueue", body, {"Content-Type": "application/json"}) + out = json.load(urllib.request.urlopen(req, timeout=10)) # {"ok":True,"id":...} + out["dest"] = dest + return out + + +def wait_for(dest, task_id, timeout=1800, poll=10): + """Block until a file for task_id lands in ; return its path (or None).""" + if not dest: + return None + end = time.time() + timeout + while time.time() < end: + if os.path.isdir(dest): + for f in sorted(os.listdir(dest)): + if f.startswith(task_id): + return os.path.join(dest, f) + time.sleep(poll) + return None + + +if __name__ == "__main__": + if "--demo" in sys.argv: + print(enqueue("Low-poly flat-shaded vinyl record crate icon, transparent background, 90s UI", + kind="image", category="djsim_ui")) + else: + print(__doc__) diff --git a/games.monsterrobot.flowrinse.plist b/games.monsterrobot.flowrinse.plist new file mode 100644 index 0000000..f212596 --- /dev/null +++ b/games.monsterrobot.flowrinse.plist @@ -0,0 +1,36 @@ + + + + + Label + games.monsterrobot.flowrinse + + + ProgramArguments + + /bin/sh + -c + exec "$HOME/Library/Application Support/flowrinse/launch.sh" + + + EnvironmentVariables + + FLOW_URL + https://labs.google/fx/tools/flow/project/4085fe36-7540-4359-810f-12c69506f78d + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + + RunAtLoad + + StartInterval + 600 + + StandardOutPath + /tmp/flowrinse.launchd.log + StandardErrorPath + /tmp/flowrinse.launchd.log + + diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..dfd2b4d --- /dev/null +++ b/install.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Install (or resync) the self-healing launchd agent for the Flow rinse. +# Run ON the rig Mac, from this directory: ./install.sh +# +# Mirrors the vault-backup pattern (mrpgi-vault/backup.sh): launchd can't +# execute scripts inside ~/Documents (macOS TCC blocks it), so the agent runs +# a COPY of the runtime at "~/Library/Application Support/flowrinse/". +# Once the agent owns the server, queue.jsonl / results.jsonl / downloads +# live THERE — enqueue via http://localhost:8017/enqueue as usual. +# +# Re-run after editing launch.sh / local_server_example.py to resync the copy. +# +# One-time macOS prompts on first run (click Allow on the rig's screen): +# * Automation: "sh"/"osascript" wants to control "Brave Browser" +# +# Manage the job: +# launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now +# launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it +set -e + +DIR="$(cd "$(dirname "$0")" && pwd)" +APP="$HOME/Library/Application Support/flowrinse" +LABEL=games.monsterrobot.flowrinse +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" + +# runtime copy (code always refreshed; live state never overwritten) +mkdir -p "$APP" +cp "$DIR/launch.sh" "$DIR/local_server_example.py" "$DIR/flowclient.py" "$APP/" +chmod +x "$APP/launch.sh" +for f in queue.jsonl results.jsonl; do + [ -f "$APP/$f" ] || { [ -f "$DIR/$f" ] && cp "$DIR/$f" "$APP/$f" || true; } +done + +mkdir -p "$HOME/Library/LaunchAgents" +cp "$DIR/$LABEL.plist" "$PLIST" +launchctl bootout gui/$(id -u)/$LABEL 2>/dev/null || true +launchctl bootstrap gui/$(id -u) "$PLIST" + +echo "installed $LABEL — fires at load + every 10 min" +echo "run now: launchctl kickstart -k gui/$(id -u)/$LABEL" diff --git a/launch.sh b/launch.sh new file mode 100755 index 0000000..330df26 --- /dev/null +++ b/launch.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# One-command Flow rinse launcher (macOS). Not headless — Brave must be logged in — +# but fully unattended once the tab is open and Auto-start is ticked in the popup. +# +# ./launch.sh https://labs.google/fx/tools/flow/project/XXXXXXXX +# FLOW_URL=... ./launch.sh +# +# Does three things: start the queue server if it's down, keep the Mac awake, and +# open OR focus a single Flow tab in Brave (never spawns duplicates). +# +# Scheduled every 10 min via launchd for self-healing (see install.sh). +# NOTE: launchd can't execute scripts inside ~/Documents (macOS TCC blocks it), +# so the agent runs a COPY at "~/Library/Application Support/flowrinse/" — +# after editing this file or local_server_example.py, re-run ./install.sh to resync. + +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +URL="${1:-$FLOW_URL}" +if [ -z "$URL" ]; then + echo "Usage: ./launch.sh (or set FLOW_URL)" + exit 1 +fi + +# 1. queue server on 8017 (nc check avoids consuming a task via /next-task) +if ! nc -z localhost 8017 2>/dev/null; then + ( cd "$DIR" && caffeinate -s python3 -B local_server_example.py >/tmp/flowrinse.log 2>&1 & ) + echo "started queue server + caffeinate (log: /tmp/flowrinse.log)" + sleep 1 +else + echo "queue server already up on 8017" +fi + +# 2. open or focus ONE Flow tab (reuses an existing one so it can't double-run). +# Only `activate` (steal system focus) when opening fresh — on the 10-min launchd +# tick an existing tab is re-selected inside Brave without yanking you out of +# whatever you're doing. Selecting the tab also keeps Memory Saver from discarding it. +osascript </_. (+ dest copy).""" + data = base64.b64decode(rec.get("dataBase64", "")) + ct = (rec.get("contentType") or "").split(";")[0].strip() + ext = EXT.get(ct, "mp4" if "video" in ct else "png") + name = f'{rec.get("id", "asset")}_{rec.get("idx", 0)}.{ext}' + outdir = os.path.join(DOWNLOADS, rec.get("category") or "misc") + os.makedirs(outdir, exist_ok=True) + path = os.path.join(outdir, name) + with open(path, "wb") as f: + f.write(data) + dest = rec.get("dest") # game asked for a copy in its own folder + if dest: + os.makedirs(dest, exist_ok=True) + with open(os.path.join(dest, name), "wb") as f: + f.write(data) + return path + + +def enqueue_task(rec): + """Append a game-requested task to the queue (persisted, picked up on next poll).""" + t = { + "id": rec.get("id") or f'req_{int(time.time() * 1000)}', + "kind": rec.get("kind", "image"), + "model": rec.get("model") or ("Veo 3.1 - Fast" if rec.get("kind") == "video" else "Nano Banana 2"), + "prompt": rec["prompt"], + "category": rec.get("category", "request"), + } + if rec.get("dest"): + t["dest"] = rec["dest"] + with open(QUEUE_FILE, "a") as f: + f.write(json.dumps(t) + "\n") + return t # Fallback demo queue if queue.jsonl is absent (so it runs out of the box). DEMO = [ @@ -80,17 +118,28 @@ class Handler(BaseHTTPRequestHandler): return self._send(204) # queue drained def do_POST(self): - if self.path != "/task-completed": - return self._send(404, {"error": "not found"}) n = int(self.headers.get("Content-Length", 0)) rec = json.loads(self.rfile.read(n).decode() or "{}") - rec["ts"] = time.time() - INFLIGHT.pop(rec.get("id"), None) - with open(RESULTS_FILE, "a") as f: - f.write(json.dumps(rec) + "\n") - assets = rec.get("assets") or [] - print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(assets)} asset(s)") - self._send(200, {"ok": True}) + + if self.path == "/task-completed": + rec["ts"] = time.time() + INFLIGHT.pop(rec.get("id"), None) + with open(RESULTS_FILE, "a") as f: + f.write(json.dumps(rec) + "\n") + print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)") + return self._send(200, {"ok": True}) + + if self.path == "/save": + path = save_asset(rec) + print(f"[SERVER] saved {rec.get('id')} -> {path}") + return self._send(200, {"ok": True, "path": path}) + + if self.path == "/enqueue": + t = enqueue_task(rec) + print(f"[SERVER] enqueued {t['id']} ({t['kind']})") + return self._send(200, {"ok": True, "id": t["id"]}) + + return self._send(404, {"error": "not found"}) def log_message(self, *a): # quiet the default noise pass diff --git a/manifest.json b/manifest.json index 44735b4..2b20563 100644 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,8 @@ "description": "Automate prompt execution, model selection, and video/image generation using local queue integration.", "permissions": [ "storage", - "debugger" + "debugger", + "downloads" ], "background": { "service_worker": "background.js" diff --git a/popup.html b/popup.html index 79d7818..7cb60df 100644 --- a/popup.html +++ b/popup.html @@ -37,6 +37,10 @@ + +
diff --git a/popup.js b/popup.js index ba8c3bd..cc0004c 100644 --- a/popup.js +++ b/popup.js @@ -5,6 +5,7 @@ const DEFAULT_SETTINGS = { promptSel: '', submitSel: '', resultSel: '', + autoStart: false, pollInterval: 4000, logs: [] }; @@ -16,6 +17,7 @@ const globalStatus = document.getElementById('global-status'); const statusLabel = globalStatus.querySelector('.status-label'); const logConsole = document.getElementById('log-console'); const clearLogsBtn = document.getElementById('clear-logs'); +const autoStartInput = document.getElementById('auto-start'); // Advanced inputs const selPromptInput = document.getElementById('sel-prompt'); @@ -26,6 +28,7 @@ const pollIntervalInput = document.getElementById('poll-interval'); // Load settings on startup chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => { serverUrlInput.value = settings.serverUrl; + autoStartInput.checked = !!settings.autoStart; selPromptInput.value = settings.promptSel; selGenerateInput.value = settings.submitSel; selModelInput.value = settings.resultSel; @@ -95,6 +98,7 @@ function saveSetting(key, value) { } serverUrlInput.addEventListener('change', (e) => saveSetting('serverUrl', e.target.value)); +autoStartInput.addEventListener('change', (e) => saveSetting('autoStart', e.target.checked)); selPromptInput.addEventListener('change', (e) => saveSetting('promptSel', e.target.value)); selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value)); selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));