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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 01:20:32 +10:00
parent 7ae0a73db0
commit 501431bf59
12 changed files with 388 additions and 30 deletions

3
.gitignore vendored
View File

@ -2,8 +2,9 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# local runtime output (harvested URLs) # local runtime output (harvested URLs + downloaded assets)
results.jsonl results.jsonl
downloads/
# saved SPA pages can embed auth tokens / cookies — never commit # saved SPA pages can embed auth tokens / cookies — never commit
Google Flow*.html Google Flow*.html

66
README.md Normal file
View File

@ -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/<PROJECT_ID>
```
Server comes up (under `caffeinate`), Brave focuses the Flow tab, and with
Auto-start ticked the rinse begins. Files land in
`~/Downloads/flowrinse/<category>/`.
## 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"}`.

View File

@ -1,8 +1,9 @@
// Background service worker: performs TRUSTED input via the Chrome DevTools Protocol. // Background service worker.
// Flow's agent editor ignores synthetic DOM events (isTrusted=false) — the text shows // * Trusted input via the Chrome DevTools Protocol — Flow's editor ignores synthetic
// but the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted, // DOM events (isTrusted=false); CDP Input.* events are trusted so it registers them.
// so the framework actually registers them. Requires the "debugger" permission; the user // * Asset download — fetches the harvested media URL with the page's session cookies
// sees a "started debugging this browser" banner while it runs (expected). // (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(); const attached = new Set();
@ -20,7 +21,6 @@ async function ensure(tabId) {
if (attached.has(tabId)) return; if (attached.has(tabId)) return;
try { await attach(tabId); attached.add(tabId); } try { await attach(tabId); attached.add(tabId); }
catch (e) { 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); if (/already attached/i.test(String(e.message || e))) attached.add(tabId);
else throw e; else throw e;
} }
@ -32,15 +32,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const tabId = sender.tab && sender.tab.id; const tabId = sender.tab && sender.tab.id;
(async () => { (async () => {
try { try {
if (!tabId) throw new Error('no tab id');
await ensure(tabId);
if (msg.type === 'insertText') { if (msg.type === 'insertText') {
await ensure(tabId);
await cmd(tabId, 'Input.insertText', { text: msg.text }); await cmd(tabId, 'Input.insertText', { text: msg.text });
sendResponse({ ok: true }); sendResponse({ ok: true });
} else if (msg.type === 'pressEnter') { } else if (msg.type === 'pressEnter') {
await ensure(tabId);
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER }); await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER });
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER }); await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER });
sendResponse({ ok: true }); 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') { } else if (msg.type === 'detach') {
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); } if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
sendResponse({ ok: true }); sendResponse({ ok: true });
@ -51,7 +58,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: false, error: String(e.message || e) }); 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); }); chrome.debugger.onDetach.addListener((src) => { if (src.tabId) attached.delete(src.tabId); });

View File

@ -15,6 +15,7 @@
let settings = { let settings = {
serverUrl: 'http://localhost:8017', serverUrl: 'http://localhost:8017',
isRunning: false, 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. // Optional CSS overrides — leave blank to use the resilient generic finders.
promptSel: '', promptSel: '',
submitSel: '', submitSel: '',
@ -29,6 +30,10 @@ let generationInProgress = false;
function init() { function init() {
chrome.storage.local.get(settings, (data) => { chrome.storage.local.get(settings, (data) => {
settings = { ...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'}).`); log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`);
if (settings.isRunning) startPolling(); if (settings.isRunning) startPolling();
}); });
@ -165,14 +170,40 @@ function clickConfirmIfPresent() {
function snapshotAssets() { function snapshotAssets() {
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document; const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
const urls = new Set(); 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[src]').forEach(v => push(v.src));
scope.querySelectorAll('video source[src]').forEach(s => push(s.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('video[poster]').forEach(v => push(v.poster));
scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.src); }); 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; 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 ---------------------------------------------------------- // ---- queue loop ----------------------------------------------------------
function startPolling() { function startPolling() {
@ -217,18 +248,31 @@ async function executeTask(task) {
await sleep(1500); await sleep(1500);
log('info', 'Submitted. Waiting for generation…'); log('info', 'Submitted. Waiting for generation…');
// Wait until a NEW asset appears AND the asset count stops growing. // Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick.
const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500); const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
if (!appeared) { const outcome = await waitForResultOrError(before.size, timeoutMs);
// last-chance: a quota/credit wall? if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") — skipping.');
const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120); if (outcome !== 'asset') {
throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.'); 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 }); await waitForStable(() => snapshotAssets().size, { min: before.size + 1 });
const after = snapshotAssets(); const after = snapshotAssets();
const fresh = [...after].filter(u => !before.has(u)); const fresh = [...after].filter(u => !before.has(u));
log('success', `Harvested ${fresh.length} asset(s).`); log('success', `Harvested ${fresh.length} asset(s).`);
// Browser download — uses your session, no CSP/CORS. Lands in Downloads/flowrinse/<category>/.
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); await reportCompletion(task.id, 'success', null, fresh);
} catch (err) { } catch (err) {
log('error', `Task ${task.id} failed: ${err.message}`); log('error', `Task ${task.id} failed: ${err.message}`);

49
flowclient.py Normal file
View File

@ -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 <dest> (and in flowext/downloads/<category>/).
"""
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 <dest>; 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__)

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>games.monsterrobot.flowrinse</string>
<!-- sh -c so $HOME expands; launchd doesn't expand ~ in ProgramArguments -->
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>exec "$HOME/Library/Application Support/flowrinse/launch.sh"</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>FLOW_URL</key>
<string>https://labs.google/fx/tools/flow/project/4085fe36-7540-4359-810f-12c69506f78d</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<!-- self-healing: idempotent launch.sh every 10 min (nc check + tab reuse
make repeat runs no-ops), so a dead server or closed tab comes back -->
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>600</integer>
<key>StandardOutPath</key>
<string>/tmp/flowrinse.launchd.log</string>
<key>StandardErrorPath</key>
<string>/tmp/flowrinse.launchd.log</string>
</dict>
</plist>

40
install.sh Normal file
View File

@ -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"

57
launch.sh Executable file
View File

@ -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 <flow_project_url> (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 <<OSA
tell application "Brave Browser"
set found to false
repeat with w in windows
set i to 0
repeat with t in tabs of w
set i to i + 1
if (URL of t) contains "tools/flow/project" then
set active tab index of w to i
set index of w to 1
set found to true
end if
end repeat
end repeat
if not found then
activate
open location "$URL"
end if
end tell
OSA
echo "Brave focused on Flow. With Auto-start ticked, the rinse begins on load."

View File

@ -13,12 +13,50 @@ Behaviour:
Harvested `assets` are googleusercontent URLs; they need your logged-in session to download, Harvested `assets` are googleusercontent URLs; they need your logged-in session to download,
so this just records them. ponytail: fetch+save the blobs from the extension later if you want local files. so this just records them. ponytail: fetch+save the blobs from the extension later if you want local files.
""" """
import json, os, time import base64, json, os, time
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
QUEUE_FILE = os.path.join(HERE, "queue.jsonl") QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
RESULTS_FILE = os.path.join(HERE, "results.jsonl") RESULTS_FILE = os.path.join(HERE, "results.jsonl")
DOWNLOADS = os.path.join(HERE, "downloads")
EXT = {"video/mp4": "mp4", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}
def save_asset(rec):
"""Write downloaded bytes to downloads/<category>/<id>_<idx>.<ext> (+ 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). # Fallback demo queue if queue.jsonl is absent (so it runs out of the box).
DEMO = [ DEMO = [
@ -80,17 +118,28 @@ class Handler(BaseHTTPRequestHandler):
return self._send(204) # queue drained return self._send(204) # queue drained
def do_POST(self): def do_POST(self):
if self.path != "/task-completed":
return self._send(404, {"error": "not found"})
n = int(self.headers.get("Content-Length", 0)) n = int(self.headers.get("Content-Length", 0))
rec = json.loads(self.rfile.read(n).decode() or "{}") rec = json.loads(self.rfile.read(n).decode() or "{}")
rec["ts"] = time.time()
INFLIGHT.pop(rec.get("id"), None) if self.path == "/task-completed":
with open(RESULTS_FILE, "a") as f: rec["ts"] = time.time()
f.write(json.dumps(rec) + "\n") INFLIGHT.pop(rec.get("id"), None)
assets = rec.get("assets") or [] with open(RESULTS_FILE, "a") as f:
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(assets)} asset(s)") f.write(json.dumps(rec) + "\n")
self._send(200, {"ok": True}) 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 def log_message(self, *a): # quiet the default noise
pass pass

View File

@ -5,7 +5,8 @@
"description": "Automate prompt execution, model selection, and video/image generation using local queue integration.", "description": "Automate prompt execution, model selection, and video/image generation using local queue integration.",
"permissions": [ "permissions": [
"storage", "storage",
"debugger" "debugger",
"downloads"
], ],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"

View File

@ -37,6 +37,10 @@
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button> <button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
</div> </div>
<label class="form-group" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="auto-start"> Auto-start when the Flow tab opens
</label>
<!-- Console / Activity Logs --> <!-- Console / Activity Logs -->
<div class="console-section"> <div class="console-section">
<div class="console-header"> <div class="console-header">

View File

@ -5,6 +5,7 @@ const DEFAULT_SETTINGS = {
promptSel: '', promptSel: '',
submitSel: '', submitSel: '',
resultSel: '', resultSel: '',
autoStart: false,
pollInterval: 4000, pollInterval: 4000,
logs: [] logs: []
}; };
@ -16,6 +17,7 @@ const globalStatus = document.getElementById('global-status');
const statusLabel = globalStatus.querySelector('.status-label'); const statusLabel = globalStatus.querySelector('.status-label');
const logConsole = document.getElementById('log-console'); const logConsole = document.getElementById('log-console');
const clearLogsBtn = document.getElementById('clear-logs'); const clearLogsBtn = document.getElementById('clear-logs');
const autoStartInput = document.getElementById('auto-start');
// Advanced inputs // Advanced inputs
const selPromptInput = document.getElementById('sel-prompt'); const selPromptInput = document.getElementById('sel-prompt');
@ -26,6 +28,7 @@ const pollIntervalInput = document.getElementById('poll-interval');
// Load settings on startup // Load settings on startup
chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => { chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => {
serverUrlInput.value = settings.serverUrl; serverUrlInput.value = settings.serverUrl;
autoStartInput.checked = !!settings.autoStart;
selPromptInput.value = settings.promptSel; selPromptInput.value = settings.promptSel;
selGenerateInput.value = settings.submitSel; selGenerateInput.value = settings.submitSel;
selModelInput.value = settings.resultSel; selModelInput.value = settings.resultSel;
@ -95,6 +98,7 @@ function saveSetting(key, value) {
} }
serverUrlInput.addEventListener('change', (e) => saveSetting('serverUrl', e.target.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)); selPromptInput.addEventListener('change', (e) => saveSetting('promptSel', e.target.value));
selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value)); selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value));
selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value)); selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));