Account types with the catalog's new combineCmeHolidays flag are read
through a combined-day view: holiday-session P&L entries fold into the
next full trading day (computed from the federal-holiday rules, so any
year works), the day count matches how the firm records it (Apex: Sun/
Mon/Tue around a Monday holiday is ONE day), and on the later sessions
of the stretch the earlier sessions' booked P&L carries into realized —
so day targets, already-traded gates, target-hit status and the panel's
day P&L all treat the stretch as a single day. Off by default; older
masters that don't send the flag behave exactly as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cash History rows are stamped with the Central calendar date, but trades
in the evening session (>= 5:00 PM CT) belong to the next trade date -
matching currentTradeDateCT and when Tradovate resets realizedPnL. The
mismatch made computeDayPlan count the same session twice (as a phantom
prior day and as today's realized PnL), inflating the day target into a
moving one: 40% consistency accounts showed dayTarget = base + 0.4 x
today's PnL instead of the anchored day-start target.
Roll those rows forward at ingestion, and make a successful Cash History
report authoritative for its 28-day window so rows cached under the old
calendar date are dropped rather than double-counted. An empty report
never deletes anything (it can't distinguish "no trades" from "no
data"), and a report still pending after polling now throws instead of
masquerading as an empty one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
runTrade now draws each batch from a single account-type prefix group
(largest group first) instead of slicing across prefixes; a group's
remainder fires alone on a later tick. copyTrade fills open slots with
accounts matching the positioned accounts' prefix before mismatched ones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reporter already computed targetHit per account to count it as
traded; now it also classifies each account that finished the day flat
and sends targetHit/partial/loss counts per firm and account type. A
realized loss reports as loss even on a grind day where any nonzero PnL
counts as done. Fields are additive, so an older master ignores them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the master marks an account type splitEnabled=false, big days trade
as one unsplit leg (the legacy one-and-done behavior); min-day bumps are
unaffected. Missing field — older master or pre-toggle cached catalog —
defaults to enabled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.validate-required is on every required checkout field, so the old selector's
first match was usually a text input, and on the terms row the click landed in
the empty half of a full-width <p> beside the box. Target the checkbox input in
a required row and gate on :not(:checked) so the two clicks tick box one then
box two instead of toggling the same box twice; the second is skipIfNotFound in
case a checkout has only one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The no-change path is a single `git fetch`, so polling master every minute
costs nothing and lines up with the VPS's own 60s deploy timer instead of
lagging it by up to five.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A frame-targeted step resolved the element inside the checkout iframe and
reported that iframe's URL as the result `url`. The signed-out guard keys off
`url` to decide whether the tab is on the login page, and Lucid's checkout
iframe sits on the apex host (lucidtrading.com) that its signedOutPattern
matches — so a signed-in checkout looked signed out, ran the login step, and
failed with no login button present.
Report the TOP tab's URL as `url` (from pageFrameOffset), keeping the child
frame's URL as `frameUrl`. Extension 0.14.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame-aware step vocabulary only the 0.19.0 runner understands, so the
server must require it too. Missed in the previous commit, which left the
dashboard flagging the restarted 0.19.0 runner as out of date.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selectors were only resolved against the top document, so steps targeting
the Lucid WooCommerce checkout (rendered in a lucidtrading.com iframe under
the dash.lucidtrading.com app) never matched — e.g. input#coupon_code_field.
Add an optional `frame` on click/type/waitFor naming the <iframe> element.
The extension measures the iframe in the top frame (pageFrameOffset), finds
the element across child frames (pageLocate `relative` mode), and composes
the two into desktop coordinates; the Python side is unchanged since the
result keeps the same shape. Without `frame`, every path behaves as before.
Point the Lucid checkout steps at the frame and harden the coupon selectors
(the exact field id is still unconfirmed against the live iframe).
Extension 0.13.0, runner 0.19.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The welcome now carries a personId; it lands in the instance_id setting
and rides on every hello and HTTP report from then on. The master keys
the person by that uuid, so changing instance_name moves our history to
the new name instead of starting a stranger. Read-only in Settings on
purpose — editing it by hand would detach this machine from its person.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Big days (> $1700) now trade as multiple separate legs of random size
(each >= $800, $5-rounded), resumable across pauses since the remaining
target is derived from realized PnL against a day-anchored target.
Min-day targets get a deterministic random $0-25 bump seeded per
(account, trade date) so the dashboard, reporter, and orders agree.
Strict-consistency accounts randomize only their first trade ever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The master's catalog now labels each firm with a platform (TopstepX being
the first non-Tradovate one). Sync accepts the field, defaulting anything
missing or unrecognized to tradovate so an older master still validates,
and getTradableFirms only hands Tradovate firms to the client.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A firm with no stored Tradovate login can do nothing until someone enters
one, so it no longer interleaves by name with the firms that are actually
running. Sorted at render rather than in /api/firms, which keeps serving the
catalog in the master's own order. Name order is preserved within each group.
The account-type picker showed "Auto — Flex 50k Funded", which put the
mechanism before the answer in the one place the answer is what you are
reading. It shows the type's name now, or Unassigned when nothing matched.
That first entry still clears a manual pin; the dot beside the select is what
says a pin is in effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.
The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.
Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.
firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.
The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither can be done for the running profile: UserChoice is hash-validated and
guarded by UCPD.sys since Feb 2024, and taskbar pinning lost its shell verb
years ago - both to stop installers helping themselves. What is supported is
policy, which applies when a profile is created, so this writes the
DefaultAssociationsConfiguration and taskbar layout XMLs for the next user and
does the two things that help the current one: a desktop shortcut, and opening
the Settings page where the browser choice actually has to be made.
Folded into the enrolment script's existing elevation so the install still
raises a single UAC prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The managed-device requirement is documented for Windows and macOS only, and a
Pi proved it: aarch64 Chromium fetched updates.xml with installedby=policy and
installed the CRX from a plain /etc policy file, with no enrolment, no Google
account and no Chrome. So setup-linux.sh writes the policy directly for
whichever browsers are present, and enrolment is a Windows concern again.
That also retires the ARM special case. Chromium could not be enrolled, which I
read as Chromium not being able to install this at all; the two are unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome is x86_64 only on Linux and Chromium cannot be enrolled, so on ARM the
enrolment step can never succeed. Removing load-unpacked from the installer
left those machines being told to run a script that would refuse - setup now
detects the architecture, explains why, and the summary points at the unpacked
install, which on that hardware is not a fallback but the only thing that
works. It still self-updates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Invoke-WebRequest pushes every buffer through the pipeline and is the slowest
of the three by a wide margin; curl.exe ships in System32 on every supported
Windows and resumes a partial file with -C -, which matters on a 166 MB
download that gets interrupted. Also checks the finished file against
Content-Length, because a truncated MSI fails in msiexec with an error that
says nothing about why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows PowerShell re-renders progress on every buffer, so a 120 MB download
spends most of its time drawing a bar rather than transferring - minutes for
what should be seconds, and indistinguishable from a stall while you watch the
byte count crawl.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setup asked for a token that is the same on every machine - a token is reusable
and enrols any number of browsers - so it is a default in both scripts now and
step 7 is a plain yes/no. It is a modest secret: it lets someone enrol their own
browser into the org and nothing more, and rotating it is a console click and a
one-line change.
Both scripts also install Chrome if it is absent, from Google's enterprise MSI
and the official .deb, since enrolling a browser that is not there writes a
token nothing reads. Chromium is refused rather than substituted: no cloud
management, so it could never be enrolled - and on ARM, where Chrome is not
published at all, the Linux script says so instead of installing something that
cannot do the job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both setup scripts ended by telling you to load the folder unpacked, which is
the one install that cannot update itself - and the policy install they moved
to next does nothing on an unmanaged machine. Enrolment is the step that
actually matters: with it, the extension arrives from the Admin console already
force-installed, and there is nothing else to configure on the box.
So step 7 now asks for an enrolment token instead. The Windows script elevates
itself for it - the token is machine-scoped, and that is the only UAC prompt in
the whole install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the autotrader stack's auto-deploy: fetch every minute, act when master
moves, lock against overlap, rewrite .last-check every run so a silent failure
is visible. Only extension/ and deploy/ changes publish - repacking for a docs
edit would republish an identical CRX every minute.
The guard is the part that earns its place. Chrome ignores a CRX that is not
strictly newer, so shipping extension changes without bumping manifest.json is
a no-op that looks exactly like success until someone notices the fix never
arrived. That now fails loudly instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The managed check asked Windows, but Enterprise Core is browser management by
Google: Windows reports a plain standalone machine however enrolled Chrome is.
The guard therefore blocked the exact path it recommends. It now asks Chrome
first - the dmtoken it writes on a successful enrolment, and the enrolment
token that requests one, in both registry views - and only then falls back to
domain and Entra join state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Enrolling is what lifts the off-store restriction without joining a domain, and
it is one machine-scoped token in both places Chrome looks: HKLM on Windows, a
file under /etc/opt/chrome/policies/enrollment on Linux. Both scripts take
--remove, and both say plainly that removing the token locally does not release
the seat in the Admin console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome blanks an off-store ExtensionInstallForcelist entry on an unmanaged
machine exactly as Edge does - the qualifying states are AD domain join, Entra
join, or Chrome Enterprise Core enrolment, and this box is none of them. The
guard now covers every browser and refuses up front, with the three ways
forward, instead of skipping Edge alone and letting Chrome fail the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unmanaged Edge installs extensions from the Microsoft Store and nowhere else:
it reads ExtensionInstallForcelist, blanks the value to [BLOCKED]<id>, and
reports an invalid extension id forever after. Writing that key buys nothing
and costs a permanent policy error plus a "Managed by your organization"
badge, so the script now checks dsregcmd and skips Edge with the two ways
round it. -Force writes it anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test-BrowserInstalled probed App Paths under HKLM:\SOFTWARE only. Edge and
Chrome ship 32-bit installers, so on a 64-bit box their App Paths entries live
under WOW6432Node and a 64-bit PowerShell never saw them — the browser was
skipped, silently, and -Browser All configured nothing for it.
Checks the install directories first now, with App Paths (both registry views)
as a backstop, and prints which browsers it is configuring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The droplet kept a cached answer for the new hostname long after the record
was live everywhere else, and the DNS check is a courtesy — Let's Encrypt
resolves the name itself. SKIP_DNS_CHECK=1 skips it, matching the autotrader
stack's script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They are Chromium: same CRX, same update manifest, same policy — only the
registry vendor key differs. The script now defaults to every one of the three
it finds installed, which is what the Linux counterpart already did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both installers ended by telling you to load the folder unpacked, which is the
one install that can never update itself. They now offer the policy install as
a last step instead, and only fall back to those instructions when it is
declined or fails.
Windows writes to the user's own policy hive rather than HKLM, so the step
needs no elevation and no separate admin shell; -Scope Machine is still there
for every-account installs. Linux gets the counterpart script — a managed
policy file under /etc, which is root-only because Chrome reads policy from
nowhere else on that platform.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard-hosting stack from the previous commit is gone; it was answering
a question nobody asked. What was actually needed is Chrome's real update
channel, which an unpacked extension does not have and cannot be given.
So the extension is now signed into a CRX with an update manifest beside it,
served from the droplet, and installed on the trading machines by policy
(ExtensionInstallForcelist). From there Chrome installs it, updates it on its
own schedule, and puts it back if it goes missing. The manifest gained the
`key` that fixes the extension id, so an unpacked development load keeps the
same identity as the published build.
The self-update check now branches on how it was installed: policy installs ask
Chrome to check the update server now rather than waiting out its five-hour
cycle, and unpacked loads keep the reload-from-disk path. chrome.management
.getSelf() tells the two apart and needs no permission to do it.
The signing key lives at /opt/autofirmer-updates/key.pem on the droplet and
nowhere else. It is the extension's identity: a different key is a different
extension, and no browser would take it as an update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
next start stops to install TypeScript when the config is a .ts file, which in
the container meant a yarn install into node_modules on every boot — slow
enough that the health check failed before the server answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.
Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.
And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
background.js changed with the widget-targeting fix, so the version has to
move with it: the AutoBuyer page now shows the version each capture was sent
by, and an unchanged number is how you tell a reload did not take.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second reset still failed: token solved, callback reported as fired, confirm
button never enabled. The button enabling is pure client-side state — the
site's callback storing the token — so if it stays dark the LIVE callback
never ran, whatever was reported. Picking the newest client was still a guess.
There is a precise link. reCAPTCHA numbers its response fields by widget id:
the first widget gets #g-recaptcha-response, the second
#g-recaptcha-response-1. The field the open modal is showing therefore names
the client holding its callback. Use that, fall back to newest-first when
there is no id to go on.
The run log now prints which field carried the token and which widgets were on
the page, so the next failure is diagnosable from the log instead of by
hypothesis: `filled 1 response field(s) ['g-recaptcha-response-1'], fired
grecaptcha callback (widget 1)` / `page had widgets ['0','1'], live one looked
like 1`.
Also surfaces the extension version on the AutoBuyer page. The extension has
always stamped it into every capture and nothing ever read it, so the README's
claim that a version mismatch shows there was no way to check. Chrome runs the
old service worker until an unpacked extension is reloaded by hand, which is
exactly the state that makes a deployed fix look like a broken one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First live run reset one account, then failed on the second: the confirm
button never enabled and the step reported it covered by a bare `div`.
reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and
removes none, so the second modal leaves two — and the callback walk fired the
first it found, the one belonging to the modal that already closed. The token
goes to a widget nobody is listening to, the live modal never hears it was
solved, and its button stays dark. Iteration 1 worked only because there was
one client to find. Now the newest wins: hook widgets are walked in reverse,
clients sorted by descending key, and the count of candidates is reported so
`2 widgets on the page, used the newest` says plainly when a page is stacking
stale ones.
Also from that run:
- coveredBy printed `div` and nothing else, which diagnoses nothing. It only
ever reported tag and id, and modal backdrops and captcha overlays carry
neither. It now names classes too.
- A covered target gets 8s to clear before the step fails. Most things over a
button are on their way out — a backdrop mid-fade, an overlay closing. This
does not weaken the check: still covered at the deadline still fails, and
nothing is clicked while anything is on top of it.
- The reset list's scroll container is confirmed as a class, so the
class-or-id hedge is trimmed. The run reported it as
`dashboard_body MuiBox-root mui-0`.
Extension to 0.11.1 so the dashboard can confirm the reload took. Runner
VERSION deliberately unchanged — the step vocabulary is the same, and a bump
would raise the mismatch banner for a change that does not need it. The
runner does still need restarting to pick up the covered-retry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The account list loads a screenful at a time, so a reset count larger than the
first page ran out of rows to click.
Counts the reset buttons rather than the rows, so the run log reports the
number that answers the question — are there enough accounts to reset `count`
times. A stretch of non-resettable rows does not stop it early: `stable` only
advances when the scroller was ALREADY at the bottom and nothing new matched,
and every appended batch pushes scrollHeight past scrollTop + clientHeight.
Simulated at 100 rows with the resettables in the middle, at the bottom, and
at the top only — all four layouts walk the whole list.
The container is named rather than guessed. The list scrolls inside
dashboard_body, not the document, and setting scrollTop on the wrong element
scrolls nothing at all — which fails as a low match count rather than an
error. Spelled as a selector list because the live markup is unconfirmed:
querySelector takes either form, and matching neither fails loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two Tradeify automations clicked the 2captcha *extension's* solve button
and waited for it to go green. That needed a second extension installed and
signed in, put a real mouse click on a button whose markup is 2captcha's to
change, and gave a run no way to tell "still solving" from "not installed".
Replaced with one `solveCaptcha` step. No mouse: the extension reads the
widget's sitekey out of the page, the dashboard buys a token from 2captcha,
and the extension puts it in the response field and fires the page's own
callback.
captcha-hook.js is the part that earns its keep. A widget rendered
programmatically puts nothing in the DOM — no sitekey, and no callback. Most
pages only enable submit from that callback, so filling the hidden field alone
leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha
`render` at document_start to hold on to what the page passed.
Solves go through 2captcha's residential proxy. Its API rejects host names in
proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached);
the gateway was confirmed to work addressed that way.
Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile.
NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is
detected as v2 and would be solved with the wrong task type. Everything else
unsupported fails loudly instead.
The API key and proxy credentials are hardcoded in the captcha route on
request. Unlike the sqlite database, that file is in the repo, so they travel
with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still
override; neither has a field on the Settings page.
Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair,
and a mismatch shows as a red "restart your runner" banner that blames the
wrong process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
There was no way to tell "running, nothing to pull" from "not running". The
no-change path logs nothing by design, and PM2 reports a cron-restart process
as `stopped` with ↺ 0 even while firing on schedule — I verified that against
PM2 7.0.4: a one-minute cron fired four times without the counter moving once.
scripts/.last-check is now rewritten on every run with the outcome. It is a
single overwritten line, so it needs no trimming.
The outcome is set by each exit path and written once in `finally`, rather than
calling record at each return — the first version of this did the latter and
already missed the build-failure path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clicker had no Linux support at all: pygetwindow has no X11 backend, so
_other_activate returned "window management is unsupported" and every step
failed before it could click. focus.py now has a third backend that raises the
browser with xdotool and reads the focused window's WM_CLASS to verify it came
forward - the same activate-then-confirm shape as the macOS and Windows paths.
setup-linux.sh mirrors setup-windows.bat with apt instead of winget. Three
things are specific to this platform rather than incidental:
- Node comes from NodeSource. Pi OS ships one too old for Next 16, and the LTS
line is also what better-sqlite3 publishes prebuilt arm64 binaries for.
- Python dependencies go in a virtualenv. Pi OS Bookworm enforces PEP 668, so
pip into the system interpreter fails with externally-managed-environment.
- Autostart uses an XDG ~/.config/autostart entry, the direct analogue of the
Windows Startup folder: no sudo, and it runs inside the graphical session,
which the clicker needs for DISPLAY.
Wayland is called out in four places - the session guard, diagnose.py, the
installer and both READMEs - because Pi OS on a Pi 5 defaults to it and the
clicker simply cannot work there. Wayland does not let one client synthesise
input into another, so this is a switch-to-X11 situation, not a bug to fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven sections of PowerShell - install Git, install Node, install
windows-build-tools, clone, npm install, build, run - all of which
setup-windows.bat now does, and better. The clone URL in step 4 was stale
anyway, pointing at a repo that no longer exists.
Kept the parts the script cannot do: adding firms through the UI, loading the
extension, and the AutoBuyer notes on foreground lock and display scaling. The
clicker sections now say what setup already handles and how to do it by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One line per account on every sync, which on a multi-firm instance buries
anything worth reading in `pm2 logs autofirmer`. The threshold is still
recorded on the client and still drives the dead-account checks; it just is
not narrated any more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Capped at 256 KB, trimmed back to the newest 500 lines. Done once per run
before anything is written: the no-change path logs nothing, so that is the
only point at which the file can have grown since the last run. Failures are
swallowed - housekeeping must never be the reason an update does not land.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run died with "'charmap' codec can't encode character '▶'" at step 1.
Python picks the console code page for stdout, and under PM2 - where stdout is
a pipe rather than a console - that is cp1252 on Windows. cp1252 handles the em
dashes in these files but not the run markers, so the first one raised
UnicodeEncodeError from inside run_steps and the runner reported it as a step
failure rather than an output problem.
Reconfigure stdout and stderr to UTF-8 at the top of each entry point, with
errors="replace" as a backstop for streams that cannot be reconfigured. Fixing
the encoding once beats stripping the glyphs from ~30 call sites, and covers
manual runs in a plain console too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Register-ScheduledTask failed with Access denied (0x80070005). Writing to the
root task folder needs elevation, so the claim that this install needed no
admin was simply wrong. Rather than demand UAC, use two mechanisms that need
no privileges at all:
- a Startup-folder entry (AutoFirmer.cmd) runs start-all.bat at logon
- PM2's own --cron-restart with --no-autorestart drives the 5-minute update
check, so PM2 owns the schedule it was already going to resurrect anyway
Both still run in the logged-in interactive session, which is the requirement
that ruled out a Windows service in the first place: the clicker sends real
input and needs a desktop.
pm2 save now runs after the updater is registered, so `pm2 resurrect` brings
back all three processes rather than two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pm2 delete autofirmer` on a machine that has never registered it writes
"[PM2][ERROR] Process or Namespace autofirmer not found" to stderr. PM2 ships a
PowerShell shim, and under $ErrorActionPreference = 'Stop' any native stderr
becomes a terminating NativeCommandError - so the routine "remove it if it is
there" line killed the install before a single task was registered. The 2>$null
at the call site was useless: the error is raised inside pm2.ps1.
Route every pm2 and npm call through helpers that drop to 'Continue' and judge
by $LASTEXITCODE. npm had the same latent problem, since its warnings also go
to stderr.
Also start Next directly instead of via `npm start`. On Windows npm is a .cmd
shim, so PM2 was supervising the shim while the real server ran as its child -
restarts and stops would have missed the process that actually matters.
The file is now pure ASCII. PowerShell 5.1 reads a non-BOM UTF-8 script as
ANSI, so the box-drawing characters in the comments were a latent hazard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
playwright was declared but referenced nowhere in the source — no import, no
require, no script. Its postinstall downloads several hundred MB of browsers
on every install, which setup-windows.bat had been working around with
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD. Removing the dependency removes the need for
the workaround, so that goes too.
Nothing else depends on it; the remaining lockfile mentions are Next declaring
@playwright/test as an optional peer, which installs nothing.
Instances pick this up through the normal update path: package-lock.json
changed, so update-check runs npm install and playwright disappears from
node_modules. Any browsers already downloaded on a machine live outside
node_modules and are not removed by this — see README.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
winget installed Python successfully and the very next line reported it
missing. The re-probe only checked `py -3` and `python` on PATH, and a
just-installed interpreter is not on the PATH this session inherited at start
— the same failure Git and Node already had directory probes for. Python
never got them.
Probe the standard install homes before giving up:
%LOCALAPPDATA%\Programs\Python\Python3* (per-user, winget's default)
%ProgramFiles%\Python3* (all-users)
...\Python\Launcher\py.exe, %SystemRoot%\py.exe
Globbed rather than version-pinned so a 3.13 install is found too, and the
loop body uses %%~D: a quoted for /d pattern carries its quotes through, which
would otherwise mangle every path built from "C:\Program Files\Python312".
This also covers Python installed without "Add python.exe to PATH" ticked,
which is the more common way to end up here without winget involved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instances were started by hand and updated by hand, so they drifted behind
master silently. Now: PM2 supervises the dashboard and the clicker, a logon
task brings them up, and a 5-minute task pulls, rebuilds and restarts when
master moves.
Restarting on every push is only safe because the scheduler now survives it.
It was pure in-memory state (_global.__autoTrader), so any restart silently
stopped automated trading with the dashboard simply showing it as off. It now
mirrors running/action/symbol/stopAfterAll to the settings table, and
resumeSchedulerIfPersisted() picks it back up from the getClients() bootstrap.
No sync-wait was needed there: tick() already skips while a client reports
!syncComplete and while any account holds a position.
A failed build is never deployed — the build runs before anything restarts, so
a broken push leaves the previous build serving.
start-all and update-check both warm the app with a request afterwards. That is
load-bearing: getClients() is lazily bootstrapped, so until something makes an
HTTP request the Tradovate clients, the reporter and the resumed schedule never
start. That was already true of manual restarts.
Logic lives in Node so a macOS or Linux port only needs an equivalent of
install-autostart.ps1. Python deps are hash-guarded, so the common path is one
hash and one import with no network, and failure is non-fatal since only the
clicker needs them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bare `git clone` follows whatever the remote advertises as its default
branch. Pushing --all to a fresh Gitea repo left that default pointing at
autobuyer, so setup cloned a branch predating the build fixes and failed at
`next build` on an error that had already been fixed on master.
The Gitea default is corrected, but the script no longer depends on it:
clone passes --branch master, and the update path fetches, checks out master
and pulls it by name. That also recovers a checkout already stranded on the
wrong branch, rather than needing the folder deleted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each prerequisite now follows probe -> offer install -> probe again. The
second probe matters: a freshly installed tool is never visible to `where`
in the session that installed it, since the process inherited its PATH at
start. A *_TRIED guard stops the loop at one attempt.
Node installs from the LTS package on purpose - better-sqlite3 publishes
prebuilt binaries for LTS, so this sidesteps the node-gyp compile that the
existing Node >= 23 warning covers.
Python detection runs the interpreter instead of calling `where python`.
Windows ships a stub python.exe under WindowsApps that only opens the
Microsoft Store; `where` finds it but it cannot execute anything. Asking for
sys.version_info distinguishes the two, and the py launcher is preferred
because the stub does not shadow it.
Dependency install now upgrades pip first and verifies pyautogui actually
imports, rather than trusting pip's exit code alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git for Windows only adds itself to PATH when "Git from the command line"
is selected during install, and an already-open Command Prompt keeps its
old PATH regardless — so a correct install still failed the check.
Probe the standard install locations before giving up, and when that also
fails, say which directories were searched and call out the just-installed
case explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fresh installs point at the master dashboard without manual configuration.
Seeded with INSERT OR IGNORE, so existing databases are untouched.
Note that reporter.ts requires both master_dashboard_url and instance_name
to be non-empty, so this alone does not start reporting — instance_name is
still seeded blank and set per machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standalone batch file: clones (or pulls), installs, builds, seeds the
reporter settings, and writes a start-autofirmer.bat. Prompts for the master
dashboard URL and instance name, defaulting to %COMPUTERNAME%.
Two install hazards it handles:
- better-sqlite3 has no prebuilt binary above Node 22, so install silently
falls back to node-gyp and dies without Visual Studio Build Tools. The
script warns first and names the fix if install fails anyway
- playwright is declared but referenced nowhere in the source, so its
postinstall browser download is skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other 17 symbols still seed, so they remain listed and can be switched
on from the Instruments page — they just start disabled.
Affects fresh installs only: the seed block runs only when the instruments
table is empty, so existing databases keep their current selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`npm run build` failed on a clean checkout, so nothing on master could be
built for production. `npm run dev` does not hard-fail on type errors, which
is why it went unnoticed.
- state route returned client.perContractFees, which has never existed on
TradovateClient on any branch; nothing consumed it
- mapFirmConfig omitted bannedSymbols. Type gap only: the trade path calls
isSymbolBanned() against the DB directly, so bans were always enforced
- initClient's sync callback was sync where the constructor wants
() => Promise<void>
- accessInfo and ws are assigned during async connect/auth, never in the
constructor, so they take definite-assignment assertions
- the socket payload's inline entityType union had drifted five members
behind the indirect-callback union above it, making the 'position' and
'cashBalance' branches unreachable to the compiler. Both now share a
TradovateEntityType alias. Type-only: those handlers ran fine at runtime
Behaviour is unchanged throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.
The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.
waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.
The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.
Also fixes a missing comma in the reset flow that broke the build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
focus.py raised the first browser in its list that happened to be running. On a
machine with both Chrome and Edge installed that is a coin flip, and losing it is
silent: coordinates measured from a tab in one browser, the click delivered into
a window of the other. It presents as selectors failing for no reason. A VPS with
both installed hit exactly this.
The extension now reports which browser is hosting it, and that travels with the
measurement, so the clicker raises the browser the coordinates actually came
from. Asking for a browser that is not running now fails honestly instead of
quietly raising a different one, and the verification step rejects the wrong
browser coming forward. Chromium, Opera and Vivaldi are recognised alongside
Chrome, Edge and Brave, on both platforms.
diagnose.py answers the question a remote desktop makes hard: whether the mouse
is really moving or the viewer simply is not drawing it. It moves the cursor and
reads the position back from the OS, so the answer does not depend on anything
being rendered, and it reports DPI mode, screen size, whether this is an RDP
session, and whether the browser can be raised at all. Nothing is clicked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`button.reset_btn` matched the reset control on any account row; qualifying it
with `.status-failed` keeps the repeat block from resetting healthy accounts.
Next's dev server intermittently answers a 500 while recompiling a route: it
reads a build manifest mid-write and cannot parse it. A single one of those
during the locate poll was fatal, so a blip in the pipeline killed a run partway
through an auth flow on Windows.
5xx responses and dropped connections are now a distinct TransientError, retried
until the step's own timeout. A 4xx still fails immediately — those are verdicts
about the request, not blips. If the errors persist all the way to the timeout,
the message says so rather than blaming a missing extension.
Error bodies are also summarised. A dev-server 500 replies with a full HTML page,
and printing it raw buried the one line that said what went wrong under kilobytes
of script tags.
This makes the client tolerant of the fault, which is not the same as fixing it:
the real answer on an automation host is to run a production build rather than
`next dev`, so those manifests are written once instead of continuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file said to work on `main`, but this repo's mainline is `master` and no
`main` exists — which is how a feature branch got created instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DPI awareness call added for Windows went into both entry points, but the
import only went into clicker.py, so starting the runner died immediately with
NameError: name 'focus' is not defined.
py_compile does not catch this — a missing import is a runtime error, not a
syntax one — and the tests around it stub the modules rather than starting the
process, so nothing exercised the real startup path. Verified this time by
booting the runner against a dead port, which reaches the claim loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
focus.py now verifies on Windows rather than assuming activation worked. Windows
declines to raise a window for a process that doesn't own the foreground — it
flashes the taskbar and the call returns as if it succeeded — so the runner reads
the foreground window's process back and reports a failure instead of clicking
into a background window. Same gap that was fixed on macOS earlier.
The runner also declares itself DPI-aware at startup. Without it Windows reports
a virtualised screen size and rescales the coordinates it accepts, while the
browser keeps reporting CSS pixels; on a display at 125% or 150% the two disagree
and clicks drift further off the further they are from the top-left.
Browser windows are matched on the owning process rather than the window title, so
an editor with chrome.js open is no longer mistaken for the browser. Linux now says
window management is unsupported there, rather than reporting no browser found —
pygetwindow has no X11 backend, and "no browser window" reads like Chrome is shut.
The main README gained a section on the AutoBuyer: what the three pieces are, how
to load the extension, and that neither the extension nor the runner reloads
itself when the source changes. That last point has been the cause of most of the
confusing failures so far, so it is called out in Updating too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The popup's target URL pattern was doing two jobs. As a fallback for locate
requests it is now dead — every step carries its firm's pattern. As the thing
deciding which tab gets scraped it was still load-bearing: without it, capture
falls back to whichever tab is active, which means scraping a banking or mail
tab and storing it in the dashboard's database.
So the setting goes, but the scoping moves to the manifest rather than
disappearing. host_permissions already lists exactly the hosts this extension is
allowed to read; capture now queries those (minus localhost, which is the
dashboard mirroring its own output back). The two cannot drift apart, and adding
a firm — which means adding its host to the manifest anyway — scopes capture
without a second place to remember.
With more than one firm open, capture prefers the active tab over the first
match, so it follows attention rather than tab order. That case could not arise
while a single pattern matched one site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repeat. A `repeat` block runs its steps several times, with the count either
fixed in the config or taken from an input the user sets on the dashboard. The
block is unrolled in resolveSteps before the runner sees it, so the runner needs
no loop, the run's total step count stays honest, and every iteration appears in
the log as its own line — a failure on the third purchase reads as "(3/5)"
rather than as an indistinguishable repeat of the first.
Counts are clamped server-side against the automation's declared min/max, and
expansion is capped at 400 steps and three levels of nesting. Each iteration can
be a purchase, so the number is not taken on trust from the client, and the
confirmation dialog names it before anything runs.
skipIfNotFound on a click or type step tolerates an element that is not on the
page — a cookie banner, a modal that only sometimes appears. Only absence is
tolerated. That distinction needed a new NotFoundError: previously a missing
element, an unreachable dashboard, a missing tab and a covered button all
surfaced as the same DashboardError, and skipping that whole class would mean a
step quietly passing while the extension was down.
Orphaned runs are now reaped. Only one run executes at a time, so a run left in
'running' when its runner went away blocked every future run — restarting the
daemon mid-run deadlocked the queue, which is exactly what happened. The
heartbeat decides: a runner that is gone, or up and reporting idle, is not
driving that run whatever the status column says. Gated on the busy flag rather
than elapsed time alone, since a run sitting in a waitFor gate or a sign-in wait
can legitimately go minutes without progress.
Lucid Trading is scaffolded with no automations yet. One match pattern covers
both its hosts — `*.` matches the apex as well as subdomains, confirmed against
a live tab. Its signed-out pattern is `//lucidtrading.com/` rather than
`lucidtrading.com/dashboard`: the leading slashes anchor it to the start of the
host, and without them the substring also matches dash.lucidtrading.com, which
would abort every step while properly signed in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>