* docs: design for Hyperliquid trading (crypto + HIP-3 stock perps) Design only, no feature code. Covers custody (client-side API wallet signing, recommended), agent approve/revoke flow, asset routing for core/spot/HIP-3, order ticket, user streams for POS/ORD/ACTV, safety rails, testnet-only test plan, phases, and open questions including the Hyperliquid ToS restriction on Ontario. * docs(hl-trading): plan the market key so HL perp rows reprice and trade Greptile: market_key() returns None for Future and live_price/row actions depend on it. Add Position.market and the portfolio_view work. * docs(hl-trading): keep testnet and mainnet quotes in separate maps Greptile: same market keys on both networks would let one overwrite the other in the shared quote map. Plan a network-aware live_price and mainnet-only gateway requests, with interleaved-update tests. * feat(hl): add tr-hl, a testnet-only Hyperliquid signing and trading crate tr-hl signs and sends Hyperliquid actions from the device. It covers L1 phantom-agent signing and user-signed approveAgent, the order, cancel, cancelByCloid, modify, updateLeverage, scheduleCancel and noop actions, asset ids read from perpDexs and meta, tick and lot rounding, info and exchange clients, a user-stream reducer and a nonce manager. Only testnet exists in this crate. The endpoints are the testnet hosts, the phantom agent source is always "b", hyperliquidChain is always "Testnet", and Network has one variant. A test greps the crate for mainnet hosts. The caps are constants and the trader enforces them before it signs. Orders are capped at $100 notional, leverage at 3x and market slippage at 1%. The builder field is never sent. Signatures match the Python SDK's published vectors, plus an approveAgent vector generated with SDK 0.24.0. The crate builds for wasm32. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat: Hyperliquid testnet trading in the app Hyperliquid joins LIVE next to SnapTrade, with its own opt-in in Settings > Trading > Hyperliquid and an amber TESTNET badge on the toolbar, the account, the ticket and every confirmation. - Connect an address read-only. The account's positions, orders, fills and funding then show in PORT, POS, ORD and ACTV, from Hyperliquid's websocket and never through Terminal's server. - Approve an agent on the desktop by pasting a throwaway testnet main key once. Terminal wipes it after signing and never stores it. In the browser the wallet signs instead (EIP-1193). The agent key lives in the macOS Keychain or the Linux Secret Service, and in browser memory or, by opt-in, IndexedDB under a non-extractable WebCrypto key. There is no file fallback. - The ticket's Hyperliquid variant covers market (IOC, 1% cap), limit (GTC, IOC, post-only), stop loss and take profit, reduce-only and "Set 3x" leverage. HIP-3 rows read "PERP · HL xyz (perpetual, not a share)". - Every order, cancel, cancel-all and leverage change asks for one confirmation. The worker re-reads the testnet mid and leverage and checks the caps again right before it signs. - KILL and a button in ORD cancel every open order and turn Hyperliquid orders off. The dead man's switch is opt-in. - Testnet rows price only from testnet mids. Position.market now carries the exact key, and live_price picks the map by network. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): mask addresses in Hyperliquid messages, toasts and logs Hyperliquid's errors name full addresses ("User or API Wallet 0x… does not exist", "Must deposit… User: 0x…"). mask_addresses turns each one into 0x1234…abcd. Exchange summaries, the worker's logs and every app message that shows a Hyperliquid error now go through it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): keep Hyperliquid out of SnapTrade's settings sections The offscreen render showed the Hyperliquid account under SnapTrade's Brokerage access, with SnapTrade's "reconnect with trading access" advice. That section and the default-account picker now list SnapTrade accounts only. Hyperliquid has its own section. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(hl-trading): record the v1 decisions and add screenshots and a wasm CI check Section 15 of the design now records Micky's twelve decisions as "Decided (2026-11-02)". The sections they change are updated: testnet only, Secret Service with no file fallback on Linux, a pasted throwaway key on the desktop for now, hard caps, an opt-in dead man's switch and the kill switch. A new section 16 lists what v1 builds and what is still missing. docs/img/hl-*.png holds the before (origin/master) and after screenshots. CI now builds and lints tr-hl for wasm32. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): read the agent key from the store once per account When the store had no key for the approved agent, the app asked again on every frame, which meant a Keychain or Secret Service read per frame. It now remembers the account it read and asks again only after a connect, an approval or a revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): fix the kill switch, uncertain sends, approvals and cap valuation Fixes from Greptile's first review of #37: - Totals value a margined position (a perp) by its P&L, not its notional. Hyperliquid's cash is its collateral (equity less unrealised P&L), so a reprice moves equity only by the P&L change. - KILL reads the open orders fresh on every dex, cancels them, and reads again (three rounds at most) before it reports what is left. Signed actions now run one at a time, and an order confirmed before a KILL that hasn't been signed yet is refused. - An approval answer applies only to the account that asked for it, and Remove is disabled while one is in flight. The app also never signs with a key that isn't the approved agent's. - An uncertain send blocks new orders until orderStatus finds its cloid, or until its 30 s expiresAfter window has passed without it. - Every order needs a current mid before it is valued against the $100 cap. Prices that round to zero are refused, and rounding zero no longer overflows. - The keyring check runs in the worker, not on the UI thread. - A cancel-all skips a market the registry doesn't know instead of failing, and the pasted key field's undo history is wiped once the key has signed. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): keep the dead man's switch armed while an order is unresolved From Greptile's second review of #37: an order with no clear answer blocked new sends through the same guard the dead man's switch reads, so the switch cleared its scheduled cancel just when a possibly accepted order needed it. The guard is now split. Whether this device can sign arms the switch, and allows cancels and leverage changes. Only new orders and modifies also wait for an unresolved order. KILL always goes ahead. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): size dollar amounts at the mid and let KILL skip stalled requests Fixes from Greptile's fourth review of #37 (atf636b7c): - A dollar amount on the Hyperliquid ticket (BUY xyz:INTC $10) became that many contracts. The ticket now sizes it at the testnet mid, rounded down to the market's size step. With no mid yet, or under one step, it stays a dollar amount and the ticket can't send it. Changing the market sizes the amount again. - Every /info and /exchange request has its own 10 s deadline. The browser's fetch had none, so a stalled request could hold the worker's only turn forever. - KILL no longer waits for that turn. It cancels at once, and an order confirmed before it gives up, even one still reading the market. If an order was already being sent, KILL cancels again once its answer comes, or once its expiresAfter has passed if the send got no clear answer. Single cancels and the dead man's switch don't wait for orders either. Orders, modifies and leverage changes still go one at a time, and an order with no clear answer still blocks new ones until it is found or expires. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): size dollars for the market named now and keep timed cancels in order Fixes from Greptile's review off1ff1b3: - The ticket reads its market at the start of a frame, before an edit to the market field. A dollar amount could then be sized at the old market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market). settle_size now sizes only with the market the ticket names, so the next frame sizes it for the new one. The ticket's estimate also uses that market. - Dead man's switch changes ran side by side, so a slow clear could land after a newer schedule and remove it. They now go out one at a time in the order the app made them, on a lock of their own that never waits for orders. A change that reaches the worker after a newer one is dropped. After a change with no clear answer, the next waits until it can no longer land. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): give each dead man's switch refresh a fresh deadline From Greptile's review of2f7cab6: after a change with no clear answer, every later change waited until that one could no longer land. Refreshes queue every 30 s, so with lost replies they went out every 35 s or so, each with the deadline it got when queued, and the timer could fire while Terminal was still running. - ScheduleCancel now carries how far ahead to cancel. The worker adds it to the time of signing, so a refresh that waited still gets a fresh deadline. - A newer change replaces one still waiting for its turn, so a backlog collapses into the newest. - Only a change of the other kind waits out a lost one: a timer after a clear, or a clear after a timer. A refresh that lands late only moves the timer back by one refresh, so refreshes don't wait for each other. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): make every dead man's switch change wait out a lost one From Greptile's review of73b71b8: letting a refresh go out before an earlier timer with a lost reply could no longer land was not safe. That timer could land last (the clocks may differ by seconds) and pull the deadline in, so the switch could fire while Terminal was running. Every change now waits until a lost earlier change can no longer land, whichever kind either one is. That is safe now that each change is signed, with its deadline, when it goes out, and a newer change replaces any still waiting, so a backlog can't build up stale deadlines. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(hl): sign approveAgent through a built-in Turnkey wallet, testnet only Add tr-turnkey, the client side of Terminal's built-in wallets. The device makes a P-256 key, and every Turnkey request carries a stamp made with it. The session token Turnkey issues for that key lasts 15 minutes, and Terminal accepts it only if it is bound to its own key. The client reads whoami and the wallet's Ethereum account, and signs one digest with the request alloy-signer-turnkey sends (SIGN_RAW_PAYLOAD_V2, hex payload, no hashing). On the desktop the browser returns the session to a listener on 127.0.0.1 that checks a 256-bit state. A mock feature adds a local stand-in for Turnkey's API and sign-in page for tests and screenshots. tr-hl gets sign_approve_agent_with, the only way a remote wallet's signature enters the crate. The signer sees only the digest of a testnet approveAgent, and the answer is used only if it recovers the wallet. approve_agent_hash now also refuses every signatureChainId except 0x66eee. The mock wallet holds the Python SDK's public test key and reproduces the SDK's approveAgent vector. Mainnet and 0xa4b1 approvals never reach the wallet, and the new code names no mainnet host. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(hl): design built-in Turnkey wallets for testnet trading Record Micky's decision of 2026-11-04 in §15: built-in wallets only, no browser wallet and no pasted key. The new §4.5 covers who holds which key, the browser sign-in that hands a session to a key made on the device, why a loopback redirect beats a terminal:// link, what Turnkey signs and how often, and the export through Turnkey's iframe. It explains why no Terminal server needs a Turnkey key (the Auth Proxy holds the one that creates sub-organizations) and sketches a scoped signup function as an unbuilt fallback. Agents now last 180 days by default, since each approval is a paid Turnkey signature. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(hl): replace the browser wallet and pasted key with the built-in wallet Settings › Trading › Hyperliquid now starts with "Create Wallet / Sign In". On the desktop it opens Terminal's sign-in page in the system browser and waits on a 127.0.0.1 loopback for the session; the web build opens the page in a popup and takes its postMessage. Terminal reads the wallet's address from Turnkey with requests stamped by its own key, keeps the 15-minute session in the OS secret store (memory on the web) and deletes it once it expires. "Approve Trading Key" makes the agent key on the device and has the wallet sign approveAgent through Turnkey, signing in first if there is no session. Revoke goes the same way. Agents now last 180 days by default, since each approval is a paid Turnkey signature. Orders are still signed only by the agent key. Deposit shows the address in full with a QR code and a copy button, the testnet perp account value and withdrawable from the stream, and spot USDC (new spotClearinghouseState read). Back Up Wallet warns, then opens the page's export mode. The web's EIP-1193 Connect Wallet and the desktop's pasted testnet key are gone. MainKey is left for tests and the testnet_check example. Watching an address read-only stays. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(site): add the wallet sign-in and backup page /wallet/ signs the user in with an email code or a passkey through @turnkey/core and Turnkey's Auth Proxy. A new user gets a sub-organization with one Ethereum wallet. The page then calls stamp_login for the app's public key, so the app gets a 15-minute session only its own key can use, and logs itself out. Desktop answers go to the loopback port, web answers to the opener with postMessage on the same origin. Its parameters come from the URL fragment, which never reaches a server, analytics or a Referer, and the page clears it on load. In export mode the page shows the recovery phrase in Turnkey's export iframe, so the phrase never touches the page's code or any server. It needs two public IDs in the Vercel project env and says so until they are set. @turnkey/core is pinned to 2.13.1 and @turnkey/iframe-stamper to 2.11.1. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci: build and lint tr-turnkey for wasm32 The browser build uses the wallet crate too, so check it the way tr-hl is checked. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): keep the trading key when the wallet doesn't sign a revoke Revoke used to forget the agent before Turnkey signed anything, so a failed or expired sign-in left the app without its agent while the message said nothing changed. Orders now turn off at once, and the agent is forgotten only when the revoke goes through. An approval the wallet didn't sign now says so instead of reading as Hyperliquid's refusal. The mock-URL warnings no longer print the URL. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): link the sign-in page at /wallet, its canonical URL The site serves the page at /wallet and redirects /wallet/ there. Browsers keep the fragment across that redirect, but there is no reason to rely on it. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(site): only sign in, never sign up, when backing up a wallet Export mode hid nothing: it offered "Create with a passkey", and the email code path would create a new wallet for an unknown address. Backing up now logs in to an existing wallet only. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: add before and after screenshots for built-in wallets Desktop shots are offscreen renders of the running app against a local Turnkey stand-in. Web shots come from headless Chromium on a local build. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(hl): keep sign-in answers and wallet actions on the right wallet From Greptile on #38: - Removing the wallet reset the request counter, so a cancelled sign-in's late answer could carry the next sign-in's id and end it. Ids now keep counting, and removing the wallet cancels a sign-in in progress. - A revoke or approval that needed a sign-in acted on whichever wallet the user signed in to. The sign-in now remembers the wallet the action is for, and refuses another one without changing anything. - The browser build's sign-in never timed out. Both builds now give up after 10 minutes, the limit the waiting line shows. - The sign-in page stayed on "Connecting your wallet" if Turnkey didn't issue the device's session. It now goes back to the sign-in controls with the error, so the user can retry or cancel. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
site
The download page for Terminal. It is one static page built with SvelteKit for Vercel, plus a
/download route that redirects to the latest release DMG and saves the email visitors give before
downloading.
This folder is its own npm project, separate from the Cargo workspace. Run the commands below from
site/, and set the Vercel project's Root Directory to site. It needs Node 22.17 or later, the
minimum SvelteKit supports; .npmrc turns on engine-strict, so npm install stops on older
versions.
cd site
npm install
npm run dev # http://localhost:5173
npm run build # Vercel output in .vercel/output
npm run preview # serve the production build on http://localhost:4173
npm run check
How it fits together
src/routes/+page.svelteis the page. It is prerendered and then hydrated, because the demo and the feature cards animate in the browser. Without JavaScript the page still reads: the demo shows the app window and every section is visible.src/lib/demo/is the hero demo.script.tsholds the 20-second timeline: when each command is typed, the camera moves, the captions and the sound cues.Scene.sveltedraws one frame for a given time over the app screenshot,player.svelte.tsadvances the time, andsound.tssynthesizes the sounds with the Web Audio API, so there are no audio files. The demo plays muted on a loop while it is on screen; "Play with sound" restarts it once with sound.src/lib/cards/holds the feature card animations. Each one loops only while it is on screen.- With reduced motion, the demo and cards hold one still frame and nothing fades in on scroll.
- The theme button in the nav switches the page, the demo and the cards between dark and light,
and remembers the choice. Dark is the default. The page colors are in
src/app.css, including the app's own Terminal Dark and Terminal Light colors under.app. src/lib/site.tsholds the version, architecture, minimum macOS, site URL and download URL, plus the name, title and description used by the page's metadata. Update it with each release.static/robots.txtandstatic/sitemap.xmlrepeat the site URL.src/routes/download/+server.tsanswersGET /downloadwith a 302 tohttps://github.com/michaelshimeles/terminal/releases/latest/download/Terminal.dmg. That URL works only once a GitHub release includes an asset named exactlyTerminal.dmg.- The download buttons open
src/lib/DownloadDialog.svelte, which asks for an email, sends it toPOST /download, then starts the download withGET /download. The browser remembers that it gave an email, so later downloads skip the dialog. If saving fails or takes more than 3 seconds, the download starts anyway. Without JavaScript the buttons link straight to/download.
Wallet sign-in (/wallet/)
src/routes/wallet/ is where Terminal users create or open their built-in Hyperliquid
testnet wallet (design: docs/architecture/trading-hyperliquid.md §4.5). The desktop app
opens it in the system browser and the web app in a popup. The page signs the user in with an
email code or a passkey through Turnkey's Auth Proxy (@turnkey/core). It then asks Turnkey for
a 15-minute session bound to the app's own key, hands that back, and logs out. In export mode
(#mode=export) it shows the recovery phrase in Turnkey's export iframe instead.
It reads two public build-time variables, which are identifiers and not secrets:
VITE_TURNKEY_ORGANIZATION_ID: the parent Turnkey organization.VITE_TURNKEY_AUTH_PROXY_CONFIG_ID: the Auth Proxy configuration.
Set them in the Vercel project's environment variables; the deploy workflow's vercel pull
brings them into the build. Without them the page only says that wallet sign-in isn't set up. No server route holds a
Turnkey key: the Auth Proxy creates each user's sub-organization with a key Turnkey keeps.
VITE_TURNKEY_API_BASE_URL and VITE_TURNKEY_AUTH_PROXY_URL point the SDK elsewhere (a
local stand-in); leave them unset in production.
Deploys
GitHub Actions deploys the site, not Vercel's Git integration, because the browser app at /app
is built from Rust and Vercel's build doesn't run Rust. .github/workflows/deploy-site.yml runs
scripts/build-web.sh, then vercel build and vercel deploy --prebuilt from the repository
root. Pushes to master deploy to production. Pull requests from branches in this repository
get a preview, linked from the pull request; pull requests from forks only build the app.
vercel.json turns off Vercel's own Git deployments, so each commit deploys once.
The workflow reads three repository secrets:
VERCEL_TOKEN: a Vercel access token (Account Settings › Tokens) scoped to the goshen-labs team.VERCEL_ORG_IDandVERCEL_PROJECT_ID:orgIdandprojectIdfrom.vercel/project.json, whichvercel link --scope goshen-labs --project terminalwrites.
To redeploy without a new commit, run the workflow from the Actions tab. Run on master, it
deploys to production.
vercel.json also routes the browser app. /app redirects once to /app/, which serves
static/app/index.html without a redirect, so the page's relative URLs resolve under /app/.
scripts/build-web.sh puts each build's JS and wasm in static/app/immutable/<hash>/, named
after a hash of those files, and the browser caches everything under /app/immutable/ for a
year. A new build gets new URLs, so a page never loads one build's JS with another build's wasm.
index.html and the fonts keep Vercel's default max-age=0, must-revalidate, so the browser
checks them on every visit. A request under /app/immutable/ for a file that doesn't exist gets a
404 with no-store, so that year-long header never sticks to a 404. vercel.json uses routes
rather than headers and rewrites because only routes can add that rule after the filesystem
check, the same way the SvelteKit adapter guards /_app/immutable/. npm run dev doesn't apply
vercel.json; open /app/index.html there.
Emails
POST /download adds each email to the emails sorted set in Upstash Redis, scored by when the
address was first given in milliseconds since the epoch. It reads KV_REST_API_URL and
KV_REST_API_TOKEN, declared in src/env.ts. Without them the site still builds and serves
downloads, but every save fails and logs an error.
To set them up, add Upstash for Redis to the Vercel project, which connects the database and sets both variables:
vercel link --scope goshen-labs --project terminal
vercel integration add upstash/upstash-kv --name terminal-emails
vercel env pull .env.local # for npm run dev
To list the emails, oldest first, with the read-only token the integration also sets:
set -a; . ./.env.local; set +a
curl -H "Authorization: Bearer $KV_REST_API_READ_ONLY_TOKEN" "$KV_REST_API_URL/zrange/emails/0/-1"
Images
assets-src/ holds the sources: two 3200x1920 window captures of the app (hero-dark.png and
hero-light.png), the icon (icon.svg) and the Open Graph card template (og.html).
scripts/build-images.sh regenerates everything in static/ from them: the two captures as
full-size WebP for the demo (it zooms into the chart, so they stay at 3200 px), the icon,
favicons, the touch icon and og.jpg. It needs ImageMagick, cwebp, and Google Chrome or
Chromium. The script finds google-chrome, google-chrome-stable, chromium or
chromium-browser on your PATH, then Chrome in /Applications. To use another browser, set
CHROME to its path or command name, such as CHROME=brave-browser.
./scripts/build-images.sh
The icon is the candlestick mark from src/lib/Logo.svelte. The app bundle doesn't ship an icon yet; if it
gets one, update both.