Let the master dashboard drive this instance; drop the AutoBuyer switch

Three changes that ride together because they touch the same surface.

A control link to the master. lib/master-link.ts dials out to
<master>/ws/instance and holds it open, so the dashboard can trigger a trade or
queue an AutoBuyer run here. Commands call the same functions the local pages
call — runTrade, startScheduler, queueRun — so nothing reaches the broker by a
path the UI does not also use. Reports prefer the socket and fall back to the
existing POST, which keeps older masters working unchanged.

startMasterLink is idempotent, and that matters more than it looks: getClients()
is lazy and starts the link, so the first command re-enters it from inside a
command the live socket is waiting to ack. Tearing the socket down there loses
the ack and reports a trade that actually executed as a failure — which invites
pressing the button twice.

queueRun is lifted out of the automations route so the socket and HTTP share one
copy of the input clamping that decides how many times a purchase fires.

Page capture loses its switch and gains a scope. It now runs every tick, but
only against the active tab of the last-focused window that matches
host_permissions — so it follows the page in front of you and stops when you
switch away, where before it would fall back to any firm tab left open in the
background.

autobuyer_enabled is gone entirely. The AutoBuyer is ready whenever the runner
is; a run happens because someone asked for one. That does mean a queued run
executes as soon as clicker/runner.py claims it, with no second gate — stopping
the runner is now the way to make a machine idle.

Extension to 0.10.0; it needs a manual reload at chrome://extensions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-30 22:31:31 -05:00
co-authored by Claude Opus 5
parent 0ba27161ce
commit 25289cb9b3
16 changed files with 586 additions and 190 deletions
+23 -15
View File
@@ -1,7 +1,8 @@
# AutoFirmer Capture (Chromium extension)
Polls the dashboard for an on/off flag and, while it's on, scrapes the target
tab's HTML and posts it back to the dashboard.
Scrapes the firm tab you are looking at and posts its HTML back to the
dashboard. There is no on/off switch for that — see **Scope** below for what
limits it instead.
## Install (unpacked)
@@ -12,34 +13,41 @@ tab's HTML and posts it back to the dashboard.
- **Dashboard URL** — default `http://localhost:3000`. Use the VPS IP if the
dashboard runs elsewhere.
- **Poll interval** — seconds between status checks (default 3).
- **Target URL pattern** — a Chrome match pattern like
`https://*.tradovate.com/*`. Leave blank to capture whichever tab is active.
- **Element selector** — optional CSS selector; matching elements get their
on-screen position measured alongside the HTML.
The badge shows `ON` (green) while capturing, `!` (red) if the dashboard is
unreachable, and nothing when the switch is off.
The badge shows `!` (red) if the dashboard is unreachable, and nothing otherwise
— blank is the working state. Open the popup for what the last tick actually did.
## Flow
```
AutoBuyer page --PATCH /api/autobuyer/status--> SQLite settings
extension --GET /api/autobuyer/status--> { enabled }
extension --GET /api/autobuyer/status--> { pendingLocate }
extension --POST /api/autobuyer/capture-> html + viewport + element rects
AutoBuyer page --GET /api/autobuyer/capture-> renders the HTML
```
Nothing in that loop is switchable. Every tick serves any queued clicks and then
captures the tab you are on.
## Scope
`host_permissions` is deliberately narrow — `https://*.tradeify.co/*` plus localhost
for the dashboard. The extension is technically incapable of reading any other site,
so an accidental capture of your bank or mail tab can't happen. The default
**Target URL pattern** matches, so it only ever captures the broker tab regardless
of which tab is focused.
Capture has no switch, so scope is what keeps it honest. Two limits, both
enforced by Chrome rather than by this code:
1. **`host_permissions` is deliberately narrow** — the firm sites plus localhost
for the dashboard. The extension is *technically incapable* of reading any
other site, so an accidental capture of your bank or mail tab cannot happen
however the logic here is changed.
2. **Only the tab you are on.** `pickTab()` asks for the active tab of the
last-focused window *and* a host_permissions match. A firm tab sitting in the
background is not captured, and neither is anything else. Switch away and
capture simply stops until you switch back.
To automate a different broker, add its pattern to `host_permissions` in
`manifest.json`, update the popup's target pattern, and reload the extension. Avoid
going back to `<all_urls>` — that re-enables scraping whatever tab is active.
`manifest.json` and reload the extension — that alone scopes capture to it. Avoid
going back to `<all_urls>`: it would put every tab you visit in reach, and limit
1 is the only thing standing between this and scraping your whole browser.
## Notes
+37 -28
View File
@@ -1,8 +1,15 @@
/* AutoFirmer Capture — MV3 service worker.
*
* Loop: poll GET /api/autobuyer/status. While `enabled` is true, scrape the
* target tab's HTML and POST it to /api/autobuyer/capture. The dashboard's
* AutoBuyer page renders whatever landed there.
* Loop: every tick, scrape the tab you are looking at and POST it to
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
* landed there.
*
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
* the last-focused window, and only when that tab is one of the firm sites in
* host_permissions. Sit on any other page and nothing is read or sent.
*
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one.
*/
const DEFAULTS = {
@@ -31,9 +38,11 @@ async function setState(patch) {
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
}
function setBadge(connected, enabled) {
chrome.action.setBadgeText({ text: !connected ? '!' : enabled ? 'ON' : '' });
chrome.action.setBadgeBackgroundColor({ color: !connected ? '#ef4444' : '#22c55e' });
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
* working state — there is nothing to switch on, so nothing to advertise. */
function setBadge(connected) {
chrome.action.setBadgeText({ text: connected ? '' : '!' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
}
// ── The injected scraper ────────────────────────────────────────────────────
@@ -106,20 +115,26 @@ function firmPatterns() {
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
}
async function pickTab(cfg) {
const tabs = (await chrome.tabs.query({ url: firmPatterns() }))
.filter((t) => /^https?:/.test(t.url || ''));
if (tabs.length === 0) return undefined;
// Prefer the one being looked at, so with two firms open the capture follows
// attention rather than tab order.
return tabs.find((t) => t.active) ?? tabs[0];
/** The firm tab you are actually looking at, or nothing.
*
* Deliberately no fallback to "some other firm tab that happens to be open":
* capture should report the page in front of you, not a stale background tab
* you left on a broker site hours ago. Chrome does the pattern matching, so a
* tab outside host_permissions cannot be selected here at all.
*/
async function pickTab() {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
url: firmPatterns(),
});
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
}
async function captureAndSend(cfg) {
const tab = await pickTab(cfg);
const tab = await pickTab();
if (!tab) {
await setState({ connected: true, enabled: true, error: null, waiting: 'No eligible tab to capture' });
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
return;
}
@@ -147,7 +162,6 @@ async function captureAndSend(cfg) {
await setState({
connected: true,
enabled: true,
error: null,
waiting: null,
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
@@ -454,24 +468,19 @@ async function serveLocateRequest(cfg) {
async function tick() {
const cfg = await getConfig();
let enabled;
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const status = await res.json();
enabled = !!status.enabled;
pendingLocate = Number(status.pendingLocate) || 0;
} catch (err) {
setBadge(false, false);
await setState({ connected: false, enabled: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
setBadge(false);
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
return;
}
setBadge(true, enabled);
if (!enabled) {
await setState({ connected: true, enabled: false, error: null, waiting: null });
return;
}
setBadge(true);
await setState({ connected: true });
// Serve queued clicks before capturing — the Python side is waiting on these,
// and a capture of a megabyte page shouldn't sit in front of them.
@@ -482,13 +491,13 @@ async function tick() {
if (!(await serveLocateRequest(cfg))) break;
}
} catch (err) {
await setState({ connected: true, enabled: true, error: `Locate queue: ${err.message}` });
await setState({ connected: true, error: `Locate queue: ${err.message}` });
}
try {
await captureAndSend(cfg);
} catch (err) {
await setState({ connected: true, enabled: true, error: err.message });
await setState({ connected: true, error: err.message });
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.8.0",
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
"version": "0.10.0",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [
"https://*.tradeify.co/*",
+5 -2
View File
@@ -13,8 +13,11 @@ async function load() {
async function render() {
const { state = {} } = await chrome.storage.local.get('state');
const color = !state.connected ? '#ef4444' : state.enabled ? '#22c55e' : '#94a3b8';
const label = !state.connected ? 'Disconnected' : state.enabled ? 'ON — capturing' : 'Connected — off';
// There is nothing to switch on: either it can reach the dashboard or it
// cannot. `waiting` below says why a tick captured nothing.
const color = state.connected ? '#22c55e' : '#ef4444';
const label = state.connected ? 'Capturing' : 'Disconnected';
const cap = state.lastCapture;
const detail = state.waiting