flow-extension/content.js
type-two 39d133ddfb fix: soft-detect submission + only NEW completion messages count
- countDoneMsgs baseline before submit: lingering 'has been scheduled' chatter from
  the PREVIOUS gen no longer triggers an instant post-gen reload (the self-reload bug)
- promptEchoed: chat echoing the prompt = strongest it-stuck signal, added to the
  post-Enter check; and on retry it means the earlier submit registered -> skip
  re-typing entirely (no double generation / double credits)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:54:05 +10:00

557 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Flow Auto-Pilot v2 — drives the Google Flow AGENT panel and HARVESTS outputs.
//
// Reuses DEALGOD's "poll until the SPA settles" discipline (waitForStable), NOT its
// full selector-discovery engine — Flow is one known page, not the open web, so
// auto-recipe/variance scoring would be over-engineering here.
//
// Loop: poll local queue -> type prompt into the agent box -> submit ->
// wait for a NEW asset to appear + the media grid to stop growing ->
// harvest the new video/image URL(s) -> report them back to the queue.
//
// Prereq you set ONCE by hand in Flow: Agent settings -> Confirm before generating
// = "Never", model + x4 defaults. The driver still clicks a confirm button if one
// shows up (covers both modes), but "Never" is what makes it truly unattended.
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: '',
resultSel: '', // scope for harvesting (e.g. the media library container)
pollInterval: 4000,
genTimeoutMs: 8 * 60 * 1000, // Veo clips can take minutes
// Pacing — stay under Google's per-account rate/abuse flag ("unusual activity").
taskDelayMs: 20000, // base pause between tasks (jittered up to 2x)
restEvery: 15, // after this many gens, take a longer rest
restMs: 15 * 60 * 1000, // length of that scheduled rest
autoRefreshOnJam: true, // refresh WHEN RESUMING after the abuse flag (never during the freeze)
cooldownUntil: 0, // epoch ms; set on rate-limit/rest, persists across reload
reloadOnResume: false // set by the abuse flag: hard-refresh once the freeze ends, then resume
};
let pollTimeout = null;
let generationInProgress = false;
let successCount = 0;
let consecutiveRateLimits = 0;
function init() {
chrome.storage.local.get({ ...settings, pendingHarvest: null }, (data) => {
const ph = data.pendingHarvest; delete data.pendingHarvest;
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 (ph) { // a video finished right before the crucial post-gen refresh — harvest it now
runPostReloadHarvest(ph).then(() => { if (settings.isRunning) startPolling(); });
return;
}
if (settings.isRunning) startPolling();
});
}
// ---- helpers -------------------------------------------------------------
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const visible = (el) => !!el && el.offsetWidth > 0 && el.offsetHeight > 0;
// Poll a predicate until truthy or timeout. Returns the truthy value or null.
async function waitFor(pred, maxMs = 15000, step = 400) {
const t0 = Date.now();
while (Date.now() - t0 < maxMs) {
let v; try { v = pred(); } catch (_) { v = null; }
if (v) return v;
await sleep(step);
}
return null;
}
// DEALGOD discipline: call countFn() every `step`ms; resolve once the value is
// the SAME for `stableFor` consecutive polls (the grid stopped growing), or cap.
async function waitForStable(countFn, { maxMs = settings.genTimeoutMs, step = 1500, stableFor = 2, min = 1 } = {}) {
const t0 = Date.now();
let prev = -1, stable = 0;
while (Date.now() - t0 < maxMs) {
const c = countFn();
if (c >= min && c === prev) {
if (++stable >= stableFor) return c;
} else {
stable = 0;
}
prev = c;
await sleep(step);
}
return prev;
}
// React-aware value injection (kept from v1 — this part was correct).
function setElementValue(el, value) {
if (!el) return false;
el.focus();
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
const setter = Object.getOwnPropertyDescriptor(el.__proto__, 'value')?.set;
setter ? setter.call(el, value) : (el.value = value); // bypass React's value shadowing
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.innerText = value;
el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value }));
}
return true;
}
// Read whatever text is currently in the editor (textarea value or contenteditable text).
function editorText(el) {
const t = (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') ? el.value : (el.innerText || el.textContent || '');
return (t || '').trim();
}
// Ask the background worker for a TRUSTED action via the DevTools Protocol.
// Flow's 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.
function bg(msg) {
return new Promise((resolve) => {
try { chrome.runtime.sendMessage(msg, (r) => resolve(chrome.runtime.lastError ? null : r)); }
catch (_) { resolve(null); }
});
}
// Focus, clear, then insert the prompt as trusted input. Falls back to DOM injection
// if the debugger path is unavailable. Returns chars that landed (0 = failed).
async function typeIntoEditor(el, text) {
el.focus();
await sleep(120);
try { document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } catch (_) {}
const r = await bg({ type: 'insertText', text });
if (!r || !r.ok) {
try { document.execCommand('insertText', false, text); } catch (_) {} // fallback
if (!editorText(el).length) setElementValue(el, text);
}
await sleep(200);
return editorText(el).length;
}
// Submit via a trusted Enter (what works by hand). Falls back to synthetic keys.
async function submitEditor(el) {
el.focus();
const r = await bg({ type: 'pressEnter' });
if (!r || !r.ok) {
const o = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
el.dispatchEvent(new KeyboardEvent('keydown', o));
el.dispatchEvent(new KeyboardEvent('keypress', o));
el.dispatchEvent(new KeyboardEvent('keyup', o));
}
}
// ---- element finders (few, known targets; overrides win) -----------------
function findAgentInput() {
if (settings.promptSel) { const el = document.querySelector(settings.promptSel); if (visible(el)) return el; }
// Flow's agent box is a visible contenteditable[role=textbox] or textarea.
const boxes = [
...document.querySelectorAll('div[contenteditable="true"][role="textbox"]'),
...document.querySelectorAll('div[contenteditable="true"]'),
...document.querySelectorAll('textarea')
];
return boxes.find(visible) || null;
}
// John's by-hand ritual, step 1: after a (re)load, don't type the instant the box
// exists — wait for the agent sidebar to finish hydrating, then re-find it.
async function waitForAgentReady(maxMs = 30000) {
const el = await waitFor(findAgentInput, maxMs);
if (!el) return null;
await sleep(1500);
const el2 = findAgentInput();
return visible(el2) ? el2 : null;
}
// The square STOP control that replaces send while a generation is queued —
// the "it stuck" signal you watch for by hand.
function findStopSquare() {
return [...document.querySelectorAll('button, [role="button"]')].find(b => visible(b) && !b.disabled &&
/\b(stop|cancel)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')));
}
// The submit control is the unlabeled arrow near the input. Find the nearest
// enabled button after the input, preferring aria-labels that read like "send".
function findSubmit(inputEl) {
if (settings.submitSel) { const el = document.querySelector(settings.submitSel); if (visible(el) && !el.disabled) return el; }
const scope = inputEl?.closest('form, [class*="composer"], [class*="session"], [class*="input"]') || document;
const btns = [...scope.querySelectorAll('button, [role="button"]')].filter(b => visible(b) && !b.disabled);
// ONLY a control explicitly labelled send/submit. No "last icon button" guess — that's what
// was clicking the + / menu and opening popups. If unsure, return null (trusted Enter handles it).
return btns.find(b => /\b(send|submit)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || ''))) || null;
}
// Snapshot real media URLs currently on the page (the harvest surface).
function snapshotAssets() {
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
const urls = new Set();
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('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);
}
// The account-level abuse/rate flag ("unusual activity" / "Help Center"). Persistent —
// retrying makes it worse — so this triggers a long back-off, not a "Try again".
function persistentError() {
return [...document.querySelectorAll('*')].some(e => visible(e) &&
/unusual activity|help center|temporarily (blocked|unavailable)|too many requests|try again later/i.test(e.textContent || '') &&
(e.textContent || '').length < 180);
}
// The agent's end-of-generation chatter. John's law #1: this message LIES — "scheduled /
// waiting in the queue" usually means it's actually DONE (refresh to see it).
// John's law #2: OLD messages linger in the chat history, so never match absolutely —
// COUNT matches and only act when a NEW one appears after our submit.
function countDoneMsgs() {
const m = (document.body.innerText || '').match(
/has been scheduled|waiting in the queue|check back in a few|is ready|i'?ve (generated|created)|here('s| is) (your|the) video/gi);
return m ? m.length : 0;
}
// The chat panel echoes the submitted prompt — the strongest "it stuck" signal, and
// on a retry it means the PREVIOUS submit registered (so don't type it again).
function promptEchoed(prompt) {
const needle = String(prompt).slice(0, 60).trim();
if (!needle) return false;
return [...document.querySelectorAll('div, p, span')].some(e => visible(e) &&
!e.closest('[contenteditable="true"], textarea') &&
(e.textContent || '').includes(needle) && (e.textContent || '').length < needle.length + 500);
}
// Flow's "Try again" button, shown beside the "Something went wrong" banner.
// Clicking it re-runs the SAME prompt (no re-injection needed) — the manual fix.
function findTryAgain() {
return [...document.querySelectorAll('button, [role="button"]')]
.find(b => visible(b) && !b.disabled && /^\s*try again\s*$/i.test(b.innerText || ''));
}
// Poll for: a new asset (win), or Flow's transient "Something went wrong". Flow
// errors constantly under load, so instead of failing we AUTO-CLICK "Try again"
// (what works by hand) up to maxRetries, with a cooldown so a lingering banner
// doesn't burn all the retries at once. Returns 'asset' | 'error' | null(timeout).
async function waitForResultOrError(baseCount, maxMs, maxRetries = 3) {
const t0 = Date.now();
let retries = 0, cooldownUntil = 0;
while (Date.now() - t0 < maxMs) {
if (snapshotAssets().size > baseCount) return 'asset';
if (Date.now() - t0 > 6000 && persistentError()) return 'ratelimit'; // abuse flag: back off, don't retry
const now = Date.now();
if (now > cooldownUntil) {
const btn = findTryAgain();
if (btn) {
if (retries >= maxRetries) return 'error'; // gave it maxRetries shots
retries++;
log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`);
btn.click();
cooldownUntil = now + 15000; // let it re-generate before re-checking
await sleep(4000);
continue;
}
if (now - t0 > 8000 && flowError()) return 'error'; // errored, no retry offered
}
await sleep(2500);
}
return null;
}
// ---- queue loop ----------------------------------------------------------
// Entry point (Start / reload). Kicks the first poll soon; the rest is paced.
function startPolling() {
if (pollTimeout) clearTimeout(pollTimeout);
if (settings.isRunning) scheduleNext(true);
}
// Schedule the next poll. Honours a persisted cooldown (rate-limit back-off or scheduled
// rest); otherwise waits a jittered human-ish gap so we don't machine-gun the account.
function scheduleNext(immediate = false) {
if (pollTimeout) clearTimeout(pollTimeout);
if (!settings.isRunning) return;
const now = Date.now();
let delay;
if (settings.cooldownUntil && now < settings.cooldownUntil) {
delay = settings.cooldownUntil - now;
log('system', `Cooling down ~${Math.max(1, Math.round(delay / 60000))} min before next…`);
} else if (immediate) {
delay = 2000;
} else {
const base = settings.taskDelayMs || 15000;
delay = base + Math.floor(Math.random() * base); // base .. 2*base, jittered
}
pollTimeout = setTimeout(pollOnce, delay);
}
async function pollOnce() {
if (!settings.isRunning) return;
if (generationInProgress) return scheduleNext();
if (settings.reloadOnResume) {
// Freeze is over — come back on a FRESH session before touching anything.
settings.reloadOnResume = false;
chrome.storage.local.set({ reloadOnResume: false });
log('system', 'Abuse-flag freeze over — hard refreshing before resuming.');
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return;
}
try {
const res = await fetch(`${settings.serverUrl}/next-task`);
if (res.status === 204) return scheduleNext(); // queue drained
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const task = await res.json();
if (task && task.id) { log('info', `Task ${task.id}: ${String(task.prompt).slice(0, 60)}`); await executeTask(task); }
else scheduleNext();
} catch (err) {
log('error', `Poll failed: ${err.message}`);
scheduleNext();
}
}
// John's by-hand recovery: the thing that actually clears a wedged Flow session is a
// HARD refresh (cache bypass), not "Try again". Keep the task PENDING server-side,
// count attempts in storage (survives the reload), and reload the tab.
async function requeueWithReload(task, why) {
if (persistentError()) return handleRateLimit(task); // abuse flag outranks any retry — freeze, don't reload
const key = 'requeue_' + task.id;
const n = ((await new Promise(r => chrome.storage.local.get({ [key]: 0 }, r)))[key] || 0) + 1;
if (n > 3) {
chrome.storage.local.remove(key);
log('error', `Task ${task.id}: ${why} — 3 hard-refresh retries burned, marking failed.`);
await reportCompletion(task.id, 'failed', `${why} (after 3 hard refreshes)`, []);
return;
}
chrome.storage.local.set({ [key]: n });
await reportCompletion(task.id, 'requeue', why, []); // server keeps it PENDING
log('system', `Task ${task.id}: ${why} — hard refresh ${n}/3, retrying after reload.`);
setCooldown(20000, 'Hard-refresh retry');
await sleep(800);
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
}
async function executeTask(task) {
generationInProgress = true;
chrome.storage.local.set({ status: 'working' });
try {
const input = await waitForAgentReady(30000);
if (!input) return await requeueWithReload(task, 'agent input never became ready');
const before = snapshotAssets();
const doneBase = countDoneMsgs(); // old "finished" chatter lingers — only a NEW one counts
// Soft-detect first: if the chat already echoes this prompt, the PREVIOUS attempt's
// submit registered before its reload — re-typing would double-generate. Skip to waiting.
const alreadySubmitted = promptEchoed(task.prompt);
if (alreadySubmitted) {
log('info', 'Prompt already echoed in the session (earlier submit registered) — not re-typing.');
} else {
const landed = await typeIntoEditor(input, task.prompt);
if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor');
log('info', `Prompt set (${landed} chars). Submitting…`);
await sleep(600); // John's ritual: paste, wait a sec, THEN enter
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
// "It stuck" = stop square appears, OR the box empties, OR the chat echoes the prompt.
const stuck = () => findStopSquare() || editorText(input).length === 0 || promptEchoed(task.prompt);
let took = await waitFor(stuck, 6000, 300);
if (!took) {
const submit = findSubmit(input);
if (submit) { submit.click(); log('info', 'Enter fallback → clicked submit.'); }
took = await waitFor(stuck, 6000, 300);
}
if (!took) return await requeueWithReload(task, 'submission never stuck (no stop square, no echo, prompt still in box)');
log('info', 'Submitted (soft-detect confirmed). Waiting for generation…');
}
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
if (task.kind === 'video') {
// John's law: after a video gen the page is stale AND the agent's completion
// message lies ("queued due to high demand" = usually done). The post-gen HARD
// REFRESH is crucial. Wait for a done-ish signal, let it settle, persist the
// harvest state (so the reload can't re-submit and burn credits), then reload.
const t0 = Date.now();
let signal = null;
while (Date.now() - t0 < timeoutMs) {
if (snapshotAssets().size > before.size) { signal = 'asset in grid'; break; }
if (countDoneMsgs() > doneBase) { signal = 'NEW agent completion message'; break; }
if (Date.now() - t0 > 6000 && persistentError()) { await handleRateLimit(task); return; }
if (Date.now() - t0 > 8000 && (findTryAgain() || flowError()))
return await requeueWithReload(task, 'Flow errored during video generation');
await sleep(2500);
}
if (!signal) throw new Error('Timed out — no completion signal for video.');
log('info', `Video done (${signal}). Settling 2s, then the crucial post-gen hard refresh…`);
await sleep(2500);
await new Promise(r => chrome.storage.local.set({ pendingHarvest: {
id: task.id, kind: task.kind, category: task.category, tries: 0, t: Date.now(),
before: [...before].filter(u => /^https?:/.test(u)), // blob: urls don't survive reloads
} }, r));
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return; // the fresh page harvests via runPostReloadHarvest
}
// Images: the grid updates live — wait in-page for a new asset, error, or timeout.
const outcome = await waitForResultOrError(before.size, timeoutMs);
if (outcome === 'ratelimit') { await handleRateLimit(task); return; } // long back-off, don't hammer
if (outcome === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again');
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/<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);
chrome.storage.local.remove('requeue_' + task.id);
successCount++; consecutiveRateLimits = 0;
if (settings.restEvery && successCount % settings.restEvery === 0) {
setCooldown(settings.restMs || 15 * 60 * 1000, `Scheduled rest after ${successCount} gens`);
}
} catch (err) {
log('error', `Task ${task.id} failed: ${err.message}`);
await reportCompletion(task.id, 'failed', err.message, []);
} finally {
generationInProgress = false;
chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' });
scheduleNext();
}
}
// After the crucial post-gen refresh: diff the fresh grid against the persisted
// pre-submit snapshot, download what's new, report. Retries the refresh twice if
// the asset still hasn't surfaced; never re-submits (that would double-bill credits).
async function runPostReloadHarvest(ph) {
generationInProgress = true;
try {
if (Date.now() - (ph.t || 0) > 30 * 60 * 1000) { // stale (browser was closed?) — don't guess
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', 'post-gen harvest state went stale (>30 min old)', []);
return;
}
await waitForAgentReady(30000);
const before = new Set(ph.before || []);
await waitForStable(() => [...snapshotAssets()].filter(u => /^https?:/.test(u)).length,
{ maxMs: 90000, min: before.size + 1 });
const fresh = [...snapshotAssets()].filter(u => /^https?:/.test(u) && !before.has(u));
if (!fresh.length) {
if ((ph.tries || 0) < 2) {
ph.tries = (ph.tries || 0) + 1;
await new Promise(r => chrome.storage.local.set({ pendingHarvest: ph }, r));
log('info', `Post-refresh grid shows nothing new yet — refresh ${ph.tries}/2 and re-check.`);
await sleep(10000);
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
return;
}
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', 'video generated but never surfaced in the grid after 3 loads', []);
return;
}
const cat = (ph.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}/${ph.id}_${i}.${ph.kind === 'video' ? 'mp4' : 'png'}` });
if (r && r.ok) saved++;
}
log('success', `Post-refresh harvest: ${fresh.length} asset(s), downloaded ${saved} → Downloads/flowrinse/${cat}/.`);
chrome.storage.local.remove('pendingHarvest');
chrome.storage.local.remove('requeue_' + ph.id);
await reportCompletion(ph.id, 'success', null, fresh);
successCount++; consecutiveRateLimits = 0;
} catch (err) {
log('error', `Post-refresh harvest failed: ${err.message}`);
chrome.storage.local.remove('pendingHarvest');
await reportCompletion(ph.id, 'failed', `post-refresh harvest: ${err.message}`, []);
} finally {
generationInProgress = false;
}
}
async function reportCompletion(id, status, error, assets) {
try {
await fetch(`${settings.serverUrl}/task-completed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status, error, assets }) // assets = harvested URLs
});
} catch (err) {
log('error', `Report failed: ${err.message}`);
}
}
// Persisted cooldown (survives a tab reload) — used for rate-limit back-off and rests.
function setCooldown(ms, why) {
settings.cooldownUntil = Date.now() + ms;
chrome.storage.local.set({ cooldownUntil: settings.cooldownUntil });
log('system', `${why} — pausing ${Math.round(ms / 60000)} min.`);
}
// Hit the account abuse flag ("unusual activity"). This is an ACTIVE account-level
// flag — worse than "Try again". Any activity while it's up (including reloads)
// feeds it, and it wears off on its own. So: report the task back as pending,
// then CEASE COMPLETELY for a solid 30 min (60/90 on repeat hits). The tab is
// hard-refreshed only when the freeze expires, right before resuming.
async function handleRateLimit(task) {
consecutiveRateLimits++;
const mins = Math.min(90, 30 * consecutiveRateLimits); // 30 → 60 → 90
await reportCompletion(task.id, 'ratelimited', 'unusual activity — ceasing all activity', []);
if (settings.autoRefreshOnJam) {
settings.reloadOnResume = true;
chrome.storage.local.set({ reloadOnResume: true });
}
setCooldown(mins * 60 * 1000, `⚠ Abuse flag ("unusual activity") ×${consecutiveRateLimits} — total freeze`);
}
function log(level, text) {
console.log(`[Flow][${level}] ${text}`);
chrome.storage.local.get({ logs: [] }, (r) => {
chrome.storage.local.set({ logs: [...r.logs, { level, text, timestamp: Date.now() }].slice(-100) });
});
}
chrome.storage.onChanged.addListener((c, area) => {
if (area !== 'local') return;
for (const k of Object.keys(c)) if (k in settings) settings[k] = c[k].newValue;
if (c.isRunning) { c.isRunning.newValue ? startPolling() : (pollTimeout && clearTimeout(pollTimeout)); }
});
init();