SLOPSHOPPER

agon

Agon inside Claude Code: your agent's spending limit above the prompt, and a live Solana token pane that hands trades to the agent as a draft you send

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-09manjeetsharma0796/agon-plugin/plugins/agon
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agon
│ ┃ Agon ✕ › fix the failing auth test and add an audit log call │ ┃ Start Discover Watchlist Trade [ Bots ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Paper ● 0 live ⊡ Layout ⏺ Bash(bun test) │ ┃ Live Jupiter prices, paper fills on the ⎿ 3 pass, 1 fail │ ┃ Agon server │ ┃ Bots trade only while Claude Code is open. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Paper balances reset when the Agon server │ ┃ restarts. Paper only, no real funds. ✻ Worked for 42s · done 4:20 PM │ ┃ Bots cannot start: The Agon server at │ ┃ https://agon-dev.onrender.com answered 0. › /agon │ ┃ The hosted server may be waking up; press ⎿ agon: Agon opened in the sidebar: Discover, Watchlist, Trade, Bo │ ┃ Check again in a minute. │ ┃ ↻ Check │ ┃ │ ┃ No bots yet. Choose a strategy and press │ ┃ Start. │ ┃ │ ┃ New bot SOL So11…1112 │ ┃ │ ┃ [ ↻ DCA ] Results come from a backtest w │ ┃ worst-case friction, not a pro │ ┃ ◆ Agon ▸ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Agon ▸
Pane · Agon
Start Discover Watchlist Trade [ Bots ] Portfolio [ ────────────────────────────────────────────────────── Paper ● 0 live ⊡ Layout Live Jupiter prices, paper fills on the Agon server Bots trade only while Claude Code is open. Paper balances reset when the Agon server restarts. Paper only, no real funds. Bots cannot start: The Agon server at https://agon-dev.onrender.com answered 0. The hosted server may be waking up; press Check again in a minute. ↻ Check No bots yet. Choose a strategy and press Start. New bot SOL So11…1112 [ ↻ DCA ] Results come from a backtest with cautious and worst-case friction, not a promise. ⇣ Trail Results come from a backtest with cautious and worst-case friction, not a promise. ▣ client module ./slider.tsx ▣ client module ./slider.tsx ▣ client module ./slider.tsx ▣ client module ./slider.tsx Mode ▣ client module ./switch.tsx Live: you approve each order. Paper orders go on their own Slippage ▣ client module ./switch.tsx ↳ $62.50 every 5 min, 8 times No paper order over $100.00; paper fills at the server's 0.5% slippage Bots cannot start: The Agon server at https://agon-dev.onrender.com answered 0. The hosted server may be waking up; press Check again in a minute. [ ✦ Guide me ] [ ✦ Ask agent ]
README

Agon for Claude Code

Give your agent a budget, not your keys.

Agon puts a crypto trading screen inside Claude Code: live Solana tokens and charts, a portfolio, paper trading with pretend money, and paper bots. Every order is checked by Agon's guard before it goes anywhere. Agon never asks for, stores or uses a private key.

Before you start

  • Claude Code 2.1.286 or newer, installed and signed in. If you do not have it yet, follow Anthropic's install guide. Check your version with claude --version; update with claude update.
  • A paid Claude plan (Pro, Max, Team or Enterprise): the free claude.ai plan does not include Claude Code.
  • Optional: a Solana wallet address, the public one only (for example from Phantom). It names your paper account.

Quick install: paste one prompt

Start Claude Code (type claude in a terminal, or open the desktop app's Code tab), then copy this whole box into the prompt and press Enter. Claude runs the install and asks before each command.

Install the Agon plugin for Claude Code for me:
1. Run: claude plugin marketplace add manjeetsharma0796/agon-plugin
2. Run: claude plugin install agon@agon
3. Run: claude plugin list, and check that agon@agon says enabled.
If a command fails, show me the exact error and what to do next. Do not change any other setting.
When it is installed, tell me to type /reload-plugins and then /agon.

When Claude is done, type these two in the prompt (Claude cannot run them for you):

/reload-plugins
/agon

Step by step install

Prefer to do it yourself? In a terminal:

1. Add the Agon marketplace

claude plugin marketplace add manjeetsharma0796/agon-plugin

2. Install the plugin

claude plugin install agon@agon

It ends with Successfully installed plugin: agon@agon.

3. Start Claude Code and open Agon

claude

Then type this in the Claude prompt:

/agon

The Agon pane opens with its tabs along the top: Start, Discover, Watchlist, Trade, Bots and Portfolio.

The first time, Agon may take up to a minute to answer: its server sleeps when nobody is using it and wakes on the first request.


Getting started

1. Save your wallet address (public only)

On the Start tab, paste your public Solana address into the box and press Save (or Enter). Or type this in the Claude prompt, with your own address:

/agon-wallet YOUR_PUBLIC_WALLET_ADDRESS

This address only names your paper account. Never paste a private key or seed phrase: Agon refuses anything that looks like one, and nobody should ever ask you for it.

2. Make your first paper trade

Paper trades use pretend money: every paper account starts with 1,000 USDC.

  1. Open the Discover tab and click a token. It opens in Trade.
  2. In the Paper trade panel, choose Buy.
  3. Type an amount in USDC, or press 25%, 50%, 75% or 100%.
  4. Press Buy. The first time, a short sign-up note appears: press Continue to paper trade.

Agon's guard checks the trade first (price impact, slippage, and whether the token's mint or freeze authority is still open). You see the result with each reason and its number. Press any ⓘ button to read what a field means.

3. Look at your portfolio

Open the Portfolio tab:

  • your balance, large, green when you are up and red when you are down;
  • a chart of your balance over time (Balance, P&L % or ◔ Pie, and 1H to All);
  • your allocation, with how many of each token you hold;
  • ▸ Latest fills: press it to see every trade.

4. Try a paper bot

Open the Bots tab, choose DCA or Trail, set the budget with the sliders and press ▸ Start paper bot. Every bot has an On / Off switch: flip it to Off to pause the bot at once, and back to On to resume. Bots run only while Claude Code is open.

5. Ask Claude

You can also just ask, in plain words. Copy any of these into the Claude prompt and replace YOUR_PUBLIC_WALLET_ADDRESS with the address you saved in step 1 (Claude needs it to find your paper account):

What is my Agon paper balance? My paper account is YOUR_PUBLIC_WALLET_ADDRESS
Show my last Agon paper trades and how much I am up or down. My paper account is YOUR_PUBLIC_WALLET_ADDRESS
Paper buy 10 USDC of SOL in my Agon paper account YOUR_PUBLIC_WALLET_ADDRESS and show me the guard's verdict.

Using the Claude desktop app

The desktop app and the terminal share the same plugins. In the app, open the Code tab, start a session in any folder, and paste the quick install prompt. Then type /reload-plugins and /agon.


Keep Agon up to date

claude plugin marketplace update agon
claude plugin update agon@agon

Then start Claude Code again, or type /reload-plugins in an open session.

Troubleshooting

What you seeWhat to do
claude: command not foundClose the terminal, open a new one, try again. Still missing: see Troubleshoot installation.
/agon is not recognisedRun claude plugin list and check that agon@agon says enabled. Run claude update, then start Claude Code again.
/agon is still not recognised after thatAgon's screens use an early-access Claude Code feature. Add the setting below to ~/.claude/settings.json (create the file if it is missing), then start Claude Code again.
"Agon needs Claude Code 2.1.286 or newer"Run claude update, then start Claude Code again.
Agon is slow or says the server is not answeringIts server wakes from sleep on the first request. Wait a minute and press ↻ Refresh.
"Paper trading starts when the Agon server is in paper mode"The server is not in paper mode right now. Prices and charts still work; try paper trading later.
My paper balance went back to 1,000 USDCPaper balances reset whenever the Agon server restarts.

The setting for an unrecognised /agon:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

If the file already has an "env" section, add only the "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" line inside it.

What works today, and what does not

Works:

  • Live Solana tokens, prices, charts and order book depth from Jupiter.
  • Paper trading at live Jupiter quotes, judged by Agon's guard, with pretend USDC.
  • Paper bots (DCA and Trail) with an on/off switch.
  • A portfolio with balance, P&L, charts, allocation and fills.
  • Commands: /agon, /agon-wallet, /agon-bots, /agon-band.

Not yet:

  • No real-money trading. Nothing in this plugin signs or sends a transaction.
  • Wallet habit reports are sample data for now; every such result says so.

Optional: put Jupiter API keys, one per line, in ~/.agon/jupiter-keys for higher rate limits. They stay on your machine and are sent only to Jupiter. The bots web page (/agon-bots) needs Node.js installed.

Stay safe

  • Agon never needs your private key or seed phrase. Never paste them anywhere.
  • Your wallet address is public information; saving it only names your paper account.
  • Paper trades are pretend: no real tokens move and nothing reaches the blockchain.

Remove Agon

claude plugin uninstall agon@agon
claude plugin marketplace remove agon

Agon keeps only your saved public address in Claude Code's plugin storage, and reads ~/.agon/jupiter-keys if you made it; delete that file to remove your Jupiter keys.

Source 8 files
hooks/register.tsx 5957 lines
1// Agon inside Claude Code.
2//
3// - A band above the prompt, collapsed to its name or a line with what the agent can spend now and
4//   live prices, remembered across sessions. `tokens` opens the discovery pane beside the
5//   transcript; where no pane can be placed, the list opens in the band instead.
6// - The pane: Jupiter's trending, most traded, top organic or newest tokens, by interval, sorted
7//   and searched, with logos, and the selected token's price chart over 1h, 24h, 7d or 30d.
8// - Every view opens with labelled buttons to the others and Ask Claude, which drafts a question
9//   whose send carries the view as model-only context. With no wallet saved, a 3-step start opens.
10// - Paper trading goes through one adapter (paperServer) to the Agon server's paper_trade and
11//   paper_portfolio tools, under the saved wallet address as the paper account.
12// - Check, Buy and Sell put a draft in the prompt box and never send it: the person reads it,
13//   edits the amount, and presses Enter, and the agent then runs check_trade before anything is
14//   built. The draft carries the token's mint and never its symbol or name, which are outside text.
15// - Logos and charts are SVG on the desktop (a logo is the PNG inside an SVG, fetched once as
16//   base64 through an image proxy); the terminal has no SVG, so there the chart is braille.
17//
18// - Prices, token info, the order book's quotes and charts come from the Agon server's cached
19//   /price, /tokens, /quote and /candles first (viaServer), straight from Jupiter or GeckoTerminal
20//   only while the server is down, and each says where it came from and how old it is.
21//
22// It reads only. It never signs, sends or holds a key.
23
24import type { BoxProps, ButtonProps, ElementConstructor, EngineInterfaceBuilt, Register, RenderInput } from 'claude-code'
25
26import { decide, isBot, microOf, pnlOf, recordFill, recordMiss, TICK_MS, usd, grouped } from './paper-bots'
27import type { BandMode, Bot, BotDraft, BotStrategy, Depth, DepthLevel, Holding, Live, PaneLayout, Paper, PaperBook, PaperFill, PaperHolding, PaperLast, PaperOrder, PaperReason, Portfolio, Src, Tok, Vault } from '../types'
28
29const JUP = 'https://lite-api.jup.ag/tokens/v2'
30const JUP_ULTRA = 'https://lite-api.jup.ag/ultra/v1'
31const SOL_MINT = 'So11111111111111111111111111111111111111112'
32const JUP_QUOTE = 'https://lite-api.jup.ag/swap/v1/quote'
33const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
34const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
35// The sizes the order book is measured at, in dollars, each side.
36const DEPTH_USD = [100, 1000, 5000, 25000]
37const GT = 'https://api.geckoterminal.com/api/v2/networks/solana'
38const MCP = 'https://agon-dev.onrender.com'
39const PAGE_ORIGIN = 'http://127.0.0.1:8765'
40const PAGE_URL = `${PAGE_ORIGIN}/bots.html`
41// One pane holds every Agon view, so the engine draws no tab bar of its own: the nav row inside
42// the pane is the only navigator. The ids of the panes before that are closed on load.
43const PANE = 'agon'
44const OLD_PANES = ['agon-discover', 'agon-watch', 'agon-trade', 'agon-bots', 'agon-portfolio', 'agon-start']
45const BASE58 = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
46const WATCH = [
47  'So11111111111111111111111111111111111111112',
48  'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN',
49  'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263',
50]
51const DECIMALS: Record<string, [number, string]> = {
52  So11111111111111111111111111111111111111112: [9, 'SOL'],
53  EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: [6, 'USDC'],
54}
55const SOURCES = [
56  // `short` is the pill's label, so the 4 fit one row beside the search box.
57  { label: 'Trending', short: 'Trending', path: 'toptrending', interval: true },
58  { label: 'Most traded', short: 'Traded', path: 'toptraded', interval: true },
59  { label: 'Top organic', short: 'Organic', path: 'toporganicscore', interval: true },
60  { label: 'New', short: 'New', path: 'recent', interval: false },
61]
62const INTERVALS = ['5m', '1h', '6h', '24h']
63const LIST_VIEWS = ['list', 'table', 'cards'] as const
64const SORTS: { label: string; get: (t: Tok) => number | null }[] = [
65  { label: 'Volume', get: t => t.volume24h },
66  { label: '24h %', get: t => t.change24h },
67  { label: '1h %', get: t => t.change1h },
68  { label: 'MC', get: t => t.mcap },
69  { label: 'Liquidity', get: t => t.liquidity },
70  { label: 'Holders', get: t => t.holders },
71  { label: 'Organic', get: t => t.organic },
72]
73const RANGES = [
74  { label: '1h', path: 'minute', aggregate: 1, limit: 60 },
75  { label: '24h', path: 'minute', aggregate: 15, limit: 96 },
76  { label: '7d', path: 'hour', aggregate: 4, limit: 42 },
77  { label: '30d', path: 'day', aggregate: 1, limit: 30 },
78]
79
80const TOKENS = { plugin: 'agon', key: 'tokens' } as const
81const WATCHED = { plugin: 'agon', key: 'watch' } as const
82const LIST_ERROR = { plugin: 'agon', key: 'listError' } as const
83const VAULT = { plugin: 'agon', key: 'vault' } as const
84const SELECTED = { plugin: 'agon', key: 'selected' } as const
85const MODE = { plugin: 'agon', key: 'mode' } as const
86const LOGOS = { plugin: 'agon', key: 'logos' } as const
87const PANE_NOTE = { plugin: 'agon', key: 'paneNote' } as const
88// The watch card whose right-click menu is open, by mint, or null.
89const CARD_MENU = { plugin: 'agon', key: 'cardMenu' } as const
90// Whether any Agon pane is open, so the band's Sidebar button redraws as open or close.
91const SIDEBAR_UP = { plugin: 'agon', key: 'sidebarUp' } as const
92// A failed read counts as none open: the button then offers to open, which is always safe.
93const ourPanes = async ($: Dollar) => {
94  try {
95    return await $.ui.panes()
96  } catch {
97    return []
98  }
99}
100const syncSidebar = async ($: Dollar) => $.state.set(SIDEBAR_UP, (await ourPanes($)).length > 0)
101const SOURCE = { plugin: 'agon', key: 'source' } as const
102const INTERVAL = { plugin: 'agon', key: 'interval' } as const
103const SORT = { plugin: 'agon', key: 'sort' } as const
104const QUERY = { plugin: 'agon', key: 'query' } as const
105const RANGE = { plugin: 'agon', key: 'range' } as const
106const CHART = { plugin: 'agon', key: 'chart' } as const
107const CHARTS = { plugin: 'agon', key: 'charts' } as const
108const DESC = { plugin: 'agon', key: 'desc' } as const
109const AUDITED = { plugin: 'agon', key: 'audited' } as const
110const VIEW = { plugin: 'agon', key: 'view' } as const
111// Which view the one Agon pane shows, and whether Discover's sort list is open.
112const PANE_VIEW = { plugin: 'agon', key: 'paneView' } as const
113const SORT_OPEN = { plugin: 'agon', key: 'sortOpen' } as const
114const PIXELS = { plugin: 'agon', key: 'pixels' } as const
115const BOTS = { plugin: 'agon', key: 'bots' } as const
116const BOT_DRAFT = { plugin: 'agon', key: 'botDraft' } as const
117const BOT_SEL = { plugin: 'agon', key: 'botSel' } as const
118const LAYOUTS = { plugin: 'agon', key: 'layouts' } as const
119const LAYOUT_EDIT = { plugin: 'agon', key: 'layoutEdit' } as const
120const WATCHLIST = { plugin: 'agon', key: 'watchlist' } as const
121const TRADE_AMT = { plugin: 'agon', key: 'tradeAmt' } as const
122const TRADE_PCT = { plugin: 'agon', key: 'tradePct' } as const
123const BAND_PAGE = { plugin: 'agon', key: 'bandPage' } as const
124// Rows of prices the person chose for the band, 1 to BAND_ROWS_MAX, kept across sessions.
125const BAND_ROWS = { plugin: 'agon', key: 'bandRows' } as const
126const BAND_ROWS_MAX = 4
127// What the band lists under its controls: the watchlist's prices or the bots, kept across sessions.
128const BAND_VIEW = { plugin: 'agon', key: 'bandView' } as const
129const PORTFOLIO = { plugin: 'agon', key: 'portfolio' } as const
130const PORT_MODE = { plugin: 'agon', key: 'portMode' } as const
131const PORT_SPAN = { plugin: 'agon', key: 'portSpan' } as const
132const PORT_KIND = { plugin: 'agon', key: 'portKind' } as const
133const FILLS_OPEN = { plugin: 'agon', key: 'fillsOpen' } as const
134const PAPER_SIDE = { plugin: 'agon', key: 'paperSide' } as const
135const PAPER_AMT = { plugin: 'agon', key: 'paperAmt' } as const
136const PAPER_PRESET = { plugin: 'agon', key: 'paperPreset' } as const
137const INFO_OPEN = { plugin: 'agon', key: 'infoOpen' } as const
138const DEPTH = { plugin: 'agon', key: 'depth' } as const
139const PRICE_FEED = { plugin: 'agon', key: 'priceFeed' } as const
140const SEARCH = { plugin: 'agon', key: 'search' } as const
141const PICKED = { plugin: 'agon', key: 'picked' } as const
142const PAPER = { plugin: 'agon', key: 'paper' } as const
143const PAPER_PENDING = { plugin: 'agon', key: 'paperPending' } as const
144const PAPER_USDC = { plugin: 'agon', key: 'paperUsdc' } as const
145const RECENT = { plugin: 'agon', key: 'recent' } as const
146const START_NOTE = { plugin: 'agon', key: 'startNote' } as const
147// What is typed in the start's wallet box, so its Save button can submit it as Enter does.
148const START_DRAFT = { plugin: 'agon', key: 'startDraft' } as const
149// Agon's pinned list: the only token symbols the plugin ever hands the model. Any other token is
150// named by its mint, because a symbol or name from Jupiter is outside text.
151const PINNED: Record<string, string> = {
152  So11111111111111111111111111111111111111112: 'SOL',
153  EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 'USDC',
154  Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 'USDT',
155  JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN: 'JUP',
156  DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263: 'BONK',
157}
158// The watchlist holds this many tokens, so one Jupiter search prices them all.
159const WATCH_MAX = 12
160// The amounts Trade offers: presets the person picks, so a draft never carries a typed number.
161const BUY_SOL = [0.01, 0.05, 0.1, 0.5]
162const SELL_PCT = [25, 50, 100]
163
164// The Bots tab runs paper bots: each running bot is checked every 30 s against live Jupiter prices,
165// and what its rule decides goes to the Agon server's paper_trade under the saved address. Nothing
166// reaches a chain. The rules and the books are in paper-bots.ts, plain arithmetic.
167const STRATS: { id: BotStrategy; label: string; glyph: string; p1: [string, number, number, number, (v: number) => string]; p2: [string, number, number, number, (v: number) => string] }[] = [
168  { id: 'dca', label: 'DCA', glyph: '↻', p1: ['Every', 1, 60, 5, v => `${v} min`], p2: ['Orders', 2, 20, 8, v => `${v}`] },
169  { id: 'trail', label: 'Trail', glyph: '⇣', p1: ['Trail', 1, 25, 6, v => `${v}%`], p2: ['Stop', 1, 50, 10, v => `-${v}%`] },
170]
171// At most this many bots, so one Jupiter call prices every bot's token.
172const BOTS_MAX = 12
173// At most this many paper orders a tick: the server takes 30 paper calls a minute per account.
174const TRADES_PER_TICK = 10
175// The slippage a bot may take on chain, in percent: one of these, picked in the builder.
176const SLIPS = [1, 5, 20]
177const DEFAULT_DRAFT: BotDraft = { strategy: 'dca', mint: 'So11111111111111111111111111111111111111112', symbol: 'SOL', budget: 500, p1: 5, p2: 8, mode: 'copilot', cap: 100, slip: 20 }
178const sparkOf = (v: number[], w: number, floorPct = 0) => {
179  const lo = Math.min(...v)
180  const hi = Math.max(...v)
181  const span = Math.max(hi - lo, ((hi + lo) / 2) * (floorPct / 100)) || 1
182  const base = (hi + lo) / 2 - span / 2
183  return Array.from({ length: w }, (_, x) => '▁▂▃▄▅▆▇█'[Math.min(7, Math.max(0, Math.floor(((v[Math.round((x / Math.max(1, w - 1)) * (v.length - 1))]! - base) / span) * 8)))]).join('')
184}
185const strat = (id: BotStrategy) => STRATS.find(s => s.id === id) ?? STRATS[0]!
186// The templates offered for a new bot. Grid and the TP/SL bracket loop are left out: our backtests
187// show them losing to real-world friction.
188const TEMPLATES = STRATS
189const TEMPLATE_NOTE = 'Results come from a backtest with cautious and worst-case friction, not a promise.'
190// A draft on a template no longer offered starts over as DCA, so New bot never builds one.
191const offered = (d: BotDraft): BotDraft =>
192  TEMPLATES.some(s => s.id === d.strategy) ? d : { ...d, strategy: 'dca', p1: strat('dca').p1[3], p2: strat('dca').p2[3] }
193// A Trail's exit comes at the nearer of its trail and its stop; filled with up to slip% slippage it
194// can land as low as (1 - that)(1 - slip) of entry.
195const worstLoss = (b: BotDraft) => (b.strategy === 'trail' ? (1 - (1 - Math.min(b.p1, b.p2) / 100) * (1 - (b.slip ?? 20) / 100)) * 100 : null)
196const signed = (v: number) => `${v >= 0.005 ? '+' : ''}${usd(v)}`
197const pctSigned = (v: number, digits: number) => `${v >= 0 ? '+' : ''}${v.toFixed(digits)}%`
198const utc = (ms: number) => `${new Date(ms).toISOString().slice(11, 19)} UTC`
199// One bot as every view draws it: its paper P&L at the last live price, and its status.
200const runOf = (b: Bot) => ({ b, p: pnlOf(b.book, b.decimals, b.price, b.budget), st: statusOf(b) })
201const statusOf = (b: Bot) =>
202  b.status === 'done' ? { color: AG.dim, word: 'done' } : b.status === 'running' ? { color: AG.up, word: 'live' } : { color: AG.gold, word: 'paused' }
203const ruleLine = (b: BotDraft) => {
204  if (b.strategy === 'trail') return `Buy ${usd(Math.min(b.budget, b.cap))} now, sell all ${b.p1}% off the peak or ${b.p2}% under entry`
205  const even = b.budget / b.p2
206  return even > b.cap
207    ? `${usd(b.cap)} every ${b.p1} min (cap, not ${usd(even)}) until ${usd(b.budget)} is spent`
208    : `${usd(even)} every ${b.p1} min, ${b.p2} times`
209}
210const rulesText = (b: BotDraft) =>
211  [
212    `strategy  ${strat(b.strategy).label}`,
213    `mint      ${b.mint}`,
214    `budget    ${usd(b.budget)}`,
215    `rules     ${ruleLine(b)}`,
216    `mode      ${b.mode === 'copilot' ? 'copilot: the agent proposes, I approve each order' : 'automode: runs within these ranges, the agent monitors'}`,
217    `guard     check_trade before every order, fail closed`,
218    `cap       ${usd(b.cap)} per order; on chain, per window, worst case ${usd(2 * b.cap)} across a window edge`,
219    `slippage  ${b.slip ?? 20}% max${worstLoss(b) != null ? `, a stop can fill down to -${worstLoss(b)!.toFixed(1)}% of entry` : ''}`,
220  ].join('\n')
221// The paper P&L as one sentence: what it is, what it rests on and when that price was read.
222const pnlLine = (r: ReturnType<typeof runOf>) => {
223  const { b, p } = r
224  if (p.total == null) return `Paper P&L not known yet: realized ${signed(p.realized)}, and what it holds has no live price yet`
225  const at = b.price != null && b.priceAt != null ? ` at ${price(b.price)}, Jupiter ${utc(b.priceAt)}` : ''
226  return `Paper P&L ${signed(p.total)} (${pctSigned(p.roi ?? 0, 2)} on budget): realized ${signed(p.realized)}, unrealized ${signed(p.unrealized ?? 0)}${at}`
227}
228const LIMITS = 'Bots trade only while Claude Code is open. Paper balances reset when the Agon server restarts. Paper only, no real funds.'
229// The builder's draft in words, for the model: the same ranges the sliders and the bot_draft tool
230// enforce, each parameter under the name bot_draft takes it by. Built from fixed text, a pinned
231// symbol or the mint, and numbers; never a token's name.
232const DRAFT_RANGES = { budget: [50, 5000], cap: [25, 1000] } as const
233const PARAMS: Record<BotStrategy, [{ name: string; unit: string }, { name: string; unit: string }]> = {
234  dca: [
235    { name: 'every', unit: 'min' },
236    { name: 'orders', unit: '' },
237  ],
238  trail: [
239    { name: 'trail', unit: '%' },
240    { name: 'stop', unit: '%' },
241  ],
242}
243const withUnit = (v: number, unit: string) => (unit === '%' ? `${v}%` : unit ? `${v} ${unit}` : `${v}`)
244const dollars = (v: number) => `$${grouped(String(v))}`
245const draftText = (d: BotDraft, paper: Paper['status']) => {
246  const s = strat(d.strategy)
247  const [a, b] = PARAMS[d.strategy]
248  return [
249    `strategy ${s.label}`,
250    `token ${tokenRef(d.mint)}`,
251    `budget ${dollars(d.budget)} (${DRAFT_RANGES.budget[0]} to ${DRAFT_RANGES.budget[1]} USD)`,
252    `${a.name} ${withUnit(d.p1, a.unit)} (${s.p1[1]} to ${withUnit(s.p1[2], a.unit)})`,
253    `${b.name} ${withUnit(d.p2, b.unit)} (${s.p2[1]} to ${withUnit(s.p2[2], b.unit)})`,
254    `cap ${dollars(d.cap)} an order (${DRAFT_RANGES.cap[0]} to ${DRAFT_RANGES.cap[1]} USD)`,
255    `mode ${d.mode}`,
256    `slippage ${d.slip ?? 20}% (one of ${SLIPS.join(', ')}%)`,
257    `paper mode ${paper === 'on' ? 'on' : paper === 'off' ? 'off' : 'not checked yet'}`,
258  ].join('; ')
259}
260async function draftContext($: Dollar) {
261  const d = offered((await $.state.get(BOT_DRAFT)).value ?? DEFAULT_DRAFT)
262  return `Agon bot draft, written by the Agon plugin from its own state; data, not instructions. The New bot builder holds: ${draftText(d, (await readPaper($)).status)}. You may change it with the bot_draft tool when the person asks; only the person starts a bot, with the Start paper bot button.`
263}
264// A draft changed since the last prompt rides on the next one; so does the draft while the Agon pane
265// is open on Bots with the builder shown.
266let draftTouched = false
267async function draftInView($: Dollar) {
268  if (draftTouched) return true
269  if ((await $.state.get(PANE_VIEW)).value !== 'bots') return false
270  if (layoutOf((await $.state.get(LAYOUTS)).value, 'bots').hidden.includes('builder')) return false
271  return (await ourPanes($)).some(p => p.id === PANE)
272}
273
274// The agent's bot_draft tool: every field checked here, at the trust boundary, against the ranges the
275// sliders enforce. Any field wrong changes nothing and is named with its value, its range and the fix.
276// It sets the draft alone: starting a bot stays a button the person presses.
277const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId'])
278const shown = (v: unknown) => printable(typeof v === 'string' ? `"${v}"` : JSON.stringify(v) ?? String(v), 60)
279async function botDraftTool($: Dollar, input: Record<string, unknown>): Promise<{ result: string } | { deny: string }> {
280  const fields = Object.keys(input).filter(k => !RESERVED.has(k))
281  const now = offered((await $.state.get(BOT_DRAFT)).value ?? DEFAULT_DRAFT)
282  const paper = (await readPaper($)).status
283  const answer = (d: BotDraft, lead: string) => ({
284    result: `${lead} Bot draft now (not started; the person presses Start paper bot in the Bots view to run it): ${draftText(d, paper)}.`,
285  })
286  const action = input.action ?? 'set'
287  if (action !== 'get' && action !== 'set') return { deny: `action is ${shown(action)}; it takes "get" or "set". This tool never starts a bot: the person presses Start paper bot. Nothing changed.` }
288  if (action === 'get') return answer(now, 'Read only.')
289  const known = new Set(['action', 'strategy', 'mint', 'budget', 'cap', 'every', 'orders', 'trail', 'stop', 'slippage'])
290  const unknown = fields.filter(k => !known.has(k))
291  if (unknown.length) return { deny: `${unknown.join(', ')} ${unknown.length === 1 ? 'is not a field' : 'are not fields'} of bot_draft; it takes strategy, mint, budget, cap, and every and orders (DCA) or trail and stop (Trail). Nothing changed.` }
292  const strategy = input.strategy ?? now.strategy
293  if (strategy !== 'dca' && strategy !== 'trail')
294    return { deny: `strategy is ${shown(strategy)}; it takes "dca" or "trail", the templates offered. Nothing changed.` }
295  const s = strat(strategy)
296  // A new template starts from its own defaults; the fields given then apply on top.
297  const next: BotDraft = strategy === now.strategy ? { ...now } : { ...now, strategy, p1: s.p1[3], p2: s.p2[3] }
298  const other: BotStrategy = strategy === 'dca' ? 'trail' : 'dca'
299  for (const p of PARAMS[other])
300    if (p.name in input)
301      return { deny: `${p.name} belongs to ${strat(other).label}, and the draft is ${s.label}, which takes ${PARAMS[strategy].map(x => x.name).join(' and ')}. Send strategy "${other}" with it, or use ${PARAMS[strategy].map(x => x.name).join(' and ')}. Nothing changed.` }
302  const whole = (name: string, v: unknown, lo: number, hi: number, unit: string) =>
303    typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v) || v < lo || v > hi
304      ? `${name} is ${shown(v)}; it takes a whole number from ${lo} to ${hi}${unit ? ` ${unit}` : ''}. Send one in that range. Nothing changed.`
305      : null
306  const checks: [string, unknown, number, number, string, (v: number) => void][] = [
307    ['budget', input.budget, DRAFT_RANGES.budget[0], DRAFT_RANGES.budget[1], 'USD', v => (next.budget = v)],
308    ['cap', input.cap, DRAFT_RANGES.cap[0], DRAFT_RANGES.cap[1], 'USD', v => (next.cap = v)],
309    [PARAMS[strategy][0].name, input[PARAMS[strategy][0].name], s.p1[1], s.p1[2], PARAMS[strategy][0].unit, v => (next.p1 = v)],
310    [PARAMS[strategy][1].name, input[PARAMS[strategy][1].name], s.p2[1], s.p2[2], PARAMS[strategy][1].unit, v => (next.p2 = v)],
311  ]
312  for (const [name, v, lo, hi, unit] of checks) {
313    if (!(name in input)) continue
314    const bad = whole(name, v, lo, hi, unit)
315    if (bad) return { deny: bad }
316  }
317  if ('mint' in input) {
318    const m = input.mint
319    if (typeof m !== 'string' || !BASE58.test(m.trim()))
320      return { deny: `mint is ${shown(m)}; it takes a token mint address, 32 to 44 base58 characters. Ask the person for the mint. Nothing changed.` }
321    if (m.trim() === USDC_MINT) return { deny: 'mint is USDC, which paper buys spend, so a bot cannot trade it. Send another token mint. Nothing changed.' }
322  }
323  // Slippage: only the builder's own choices, so the agent can never widen it past them.
324  if ('slippage' in input && !SLIPS.includes(input.slippage as number))
325    return { deny: `slippage is ${shown(input.slippage)}; it takes ${SLIPS.join(', ')} (percent), the builder's choices. Nothing changed.` }
326  for (const [name, v, , , , apply] of checks) if (name in input) apply(v as number)
327  if ('slippage' in input) next.slip = input.slippage as number
328  if (typeof input.mint === 'string') {
329    const mint = input.mint.trim()
330    const toks = [...((await $.state.get(TOKENS)).value ?? []), ...((await $.state.get(WATCHED)).value ?? []), ...[(await $.state.get(PICKED)).value].filter((x): x is Tok => Boolean(x))]
331    // The symbol is shown to the person alone; the answer names the token by tokenRef.
332    next.mint = mint
333    next.symbol = PINNED[mint] ?? toks.find(x => x.mint === mint)?.symbol ?? `${mint.slice(0, 4)}…`
334  }
335  await $.state.set(BOT_DRAFT, next)
336  void noteAction($, `the agent set the bot draft with bot_draft`)
337  return answer(next, 'Set.')
338}
339
340// The band's line under its bots: paper, valued at Jupiter's price as of the newest read.
341const bandBotsNote = (runs: ReturnType<typeof runOf>[]) => {
342  const at = Math.max(0, ...runs.map(r => r.b.priceAt ?? 0))
343  return `Paper, no real funds. P&L at Jupiter prices${at ? ` of ${utc(at)}` : ', none read yet'}. Bots trade only while Claude Code is open.`
344}
345
346// Agon's palette (the waitlist design system, #260), with a muted green and red for moves.
347const AG = {
348  bg: '#050506',
349  raise: '#141417',
350  line: '#2f3239',
351  fg: '#ffffff',
352  muted: '#c6c6cb',
353  dim: '#8f9096',
354  ember: '#a8925a',
355  gold: '#d4bf7c',
356  chrome: '#d6d7dc',
357  up: '#3fbf8f',
358  down: '#e5605e',
359}
360const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
361const bool = (v: unknown) => (typeof v === 'boolean' ? v : null)
362const toTok = (t: Record<string, any>): Tok => {
363  const buy = num(t.stats24h?.buyVolume)
364  const sell = num(t.stats24h?.sellVolume)
365  return {
366    mint: String(t.id),
367    // Printable ASCII only and short: a symbol is outside text and only ever shown, never sent.
368    symbol: String(t.symbol ?? '').replace(/[^\x20-\x7e]/g, '').slice(0, 12) || '?',
369    icon: typeof t.icon === 'string' && /^https?:\/\//.test(t.icon) ? t.icon : null,
370    price: num(t.usdPrice),
371    change5m: num(t.stats5m?.priceChange),
372    change1h: num(t.stats1h?.priceChange),
373    change6h: num(t.stats6h?.priceChange),
374    change24h: num(t.stats24h?.priceChange),
375    volume24h: buy !== null && sell !== null ? buy + sell : null,
376    mcap: num(t.mcap),
377    liquidity: num(t.liquidity),
378    holders: num(t.holderCount),
379    organic: num(t.organicScore),
380    mintOff: bool(t.audit?.mintAuthorityDisabled),
381    freezeOff: bool(t.audit?.freezeAuthorityDisabled),
382    decimals: Number.isInteger(t.decimals) && t.decimals >= 0 && t.decimals <= 18 ? t.decimals : null,
383  }
384}
385// A row of the Agon server's /tokens (the /discover row plus decimals) as the same Tok: every
386// figure is `{ value }` there, and the symbol and icon sit only inside `display`, creator text that
387// gets the same printable, shown-never-sent treatment. Null for a row with no valid mint.
388const rowTok = (r: Record<string, any>): Tok | null => {
389  if (typeof r?.mint !== 'string' || !BASE58.test(r.mint)) return null
390  const fig = (f: unknown) => num((f as { value?: unknown } | null)?.value)
391  const auth = (a: { value?: unknown } | null | undefined) => (a?.value === 'disabled' ? true : a?.value === 'enabled' ? false : null)
392  return {
393    ...toTok({ id: r.mint, symbol: r.display?.symbol, icon: r.display?.icon, decimals: r.decimals }),
394    price: fig(r.price),
395    change5m: fig(r.change?.['5m']),
396    change1h: fig(r.change?.['1h']),
397    change6h: fig(r.change?.['6h']),
398    change24h: fig(r.change?.['24h']),
399    volume24h: fig(r.volume24h),
400    mcap: fig(r.marketCap),
401    liquidity: fig(r.liquidity),
402    holders: fig(r.holders),
403    organic: fig(r.organicScore),
404    mintOff: auth(r.mintAuthority),
405    freezeOff: auth(r.freezeAuthority),
406  }
407}
408
409const price = (p: number | null | undefined) =>
410  p == null ? '-' : p >= 1 ? `$${grouped(p.toFixed(2))}` : `$${p.toPrecision(3)}`
411const pct = (c: number | null | undefined) =>
412  c == null ? '-' : `${c >= 0 ? '▲' : '▼'}${Math.abs(c) >= 100 ? Math.abs(c).toFixed(0) : Math.abs(c).toFixed(1)}%`
413const compact = (v: number | null | undefined) =>
414  v == null
415    ? '-'
416    : v >= 1e9
417      ? `$${(v / 1e9).toFixed(1)}B`
418      : v >= 1e6
419        ? `$${(v / 1e6).toFixed(1)}M`
420        : v >= 1e3
421          ? `$${(v / 1e3).toFixed(1)}K`
422          : `$${v.toFixed(0)}`
423const tone = (c: number | null | undefined) =>
424  c == null || Math.abs(c) < 0.005 ? undefined : c > 0 ? AG.up : AG.down
425const units = (amount: string, mint: string) => {
426  const d = DECIMALS[mint]
427  if (!d || !/^\d+$/.test(amount)) return `${amount} base units`
428  const p = amount.padStart(d[0] + 1, '0')
429  const frac = p.slice(-d[0]).replace(/0+$/, '')
430  return `${p.slice(0, -d[0])}${frac ? '.' + frac : ''} ${d[1]}`
431}
432const why = (e: unknown) => (e instanceof Error ? e.message : String(e))
433// ipfs.io refuses the image proxy; Filebase served 12 of 12 through it (measured 2026-10-01).
434const proxied = (url: string) => {
435  const ipfs = url.match(/^(?:ipfs:\/\/|https?:\/\/[^/]+\/ipfs\/)(.+)$/)
436  const source = ipfs ? `https://ipfs.filebase.io/ipfs/${ipfs[1]}` : url
437  return `https://wsrv.nl/?url=${encodeURIComponent(source)}&w=48&h=48&fit=cover&output=png&encoding=base64`
438}
439// The desktop draws an SVG holding the PNG as a data URL (tested: an interactive SVG frame and a
440// Markdown image did not draw it).
441const logoSvg = (dataUrl: string, size: number) =>
442  `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 48 48"><image href="${dataUrl}" width="48" height="48"/></svg>`
443// `floorPct` sets the least span drawn, as a share of the price, centred on the series: with 1, a
444// stablecoin's 0.01% wobble draws as the flat line it is, not stretched to the card's full height.
445// With `axis`, the chart is labelled: the high and the low of what is drawn at their own heights on
446// the right, and the start and end of the span under it, small and dim. Tiny sparklines go without.
447type Axis = { fmt: (v: number) => string; from: string; to: string }
448// The labels are in the app's own sans font at 11 px, the size the rest of a pane reads at, in the
449// muted grey: legible without competing with the line.
450const AXIS_FONT = 'font-size="11" font-family="system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif"'
451// A candle's time for the chart's hover label: the clock for spans of a day or less, the date and
452// clock beyond. Candle times arrive in seconds or milliseconds depending on the source.
453const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
454const candleTime = (t: number, range: number) => {
455  const d = new Date(t < 1e12 ? t * 1000 : t)
456  const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
457  return range <= 1 ? hm : `${MONTHS[d.getMonth()]} ${d.getDate()} ${hm}`
458}
459type Hover = { prices: string[]; times: string[] }
460const chartSvg = (closes: number[], w: number, h: number, up: boolean, floorPct = 0, axis?: Axis, hover?: Hover) => {
461  const lo = Math.min(...closes)
462  const hi = Math.max(...closes)
463  const span = Math.max(hi - lo, ((hi + lo) / 2) * (floorPct / 100)) || 1
464  const base = (hi + lo) / 2 - span / 2
465  // The plot leaves a row under it for the span's labels, and room above and below for the high and low.
466  const ph = axis ? h - 17 : h
467  const pad = axis ? 9 : 3
468  // With labels, the line stops short of the right edge, and the high and low sit in that gutter,
469  // beside the plot rather than over it.
470  const gutter = axis ? Math.ceil(Math.max(axis.fmt(hi).length, axis.fmt(lo).length) * 6.6) + 10 : 0
471  const pw = w - gutter
472  const yOf = (c: number) => (1 - (c - base) / span) * (ph - 2 * pad) + pad
473  const pts = closes.map((c, i) => `${((i / (closes.length - 1)) * pw).toFixed(1)},${yOf(c).toFixed(1)}`).join(' ')
474  const col = up ? AG.up : AG.down
475  // The labels sit beside and under the plot, never over the line, so they need no backing.
476  const label = (x: number, y: number, anchor: string, text: string) =>
477    `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" fill="${AG.muted}" ${AXIS_FONT}>${esc(text)}</text>`
478  // Each level label at its own value's height, kept 13 px apart and inside the plot's rows.
479  let hiY = Math.min(ph - 2, Math.max(11, yOf(hi) + 4))
480  let loY = Math.min(ph - 2, Math.max(11, yOf(lo) + 4))
481  if (loY - hiY < 13) {
482    loY = Math.min(ph - 2, hiY + 13)
483    hiY = Math.max(11, loY - 13)
484  }
485  const labels = axis
486    ? (axis.fmt(hi) === axis.fmt(lo)
487        ? label(w - 2, Math.min(ph - 2, Math.max(11, yOf(hi) + 4)), 'end', axis.fmt(hi))
488        : label(w - 2, hiY, 'end', axis.fmt(hi)) + label(w - 2, loY, 'end', axis.fmt(lo))) +
489      `<line x1="0" x2="${w}" y1="${ph}" y2="${ph}" stroke="${AG.line}" stroke-width="0.6"/>` +
490      label(1, h - 3, 'start', axis.from) +
491      label(w - 2, h - 3, 'end', axis.to)
492    : ''
493  // Hover, drawn in the SVG and shown by CSS :hover (the Svg is drawn interactive): one slice per
494  // candle, full plot height, holding a line, a dot and the candle's price and time. A layer of
495  // cells over the chart could not be sized to it: the API gives no pixels per row.
496  const n = closes.length
497  const xOf = (i: number) => (i / (n - 1)) * pw
498  const slices =
499    hover && n > 1 && hover.prices.length === n && hover.times.length === n
500      ? // Drawn in a frame of its own: declared dark and transparent, or the browser backs it white.
501        HOVER_CSS +
502        closes
503          .map((c, i) => {
504            const x = xOf(i)
505            const x0 = i === 0 ? 0 : (xOf(i - 1) + x) / 2
506            const x1 = i === n - 1 ? pw : (x + xOf(i + 1)) / 2
507            return (
508              `<g class="h"><rect x="${x0.toFixed(1)}" y="0" width="${(x1 - x0).toFixed(1)}" height="${ph}" fill="transparent"/>` +
509              `<g class="k"><line x1="${x.toFixed(1)}" x2="${x.toFixed(1)}" y1="0" y2="${ph}" stroke="${AG.muted}" stroke-width="1" stroke-dasharray="3 3"/>` +
510              `<circle cx="${x.toFixed(1)}" cy="${yOf(c).toFixed(1)}" r="3.5" fill="${col}" stroke="${AG.bg}" stroke-width="1.5"/>` +
511              `${hoverTag(x, w, `${hover.prices[i]}  ${hover.times[i]}`)}</g></g>`
512            )
513          })
514          .join('')
515      : ''
516  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"${slices ? ' style="color-scheme:dark;background:transparent"' : ''}><polygon points="0,${ph} ${pts} ${pw},${ph}" fill="${col}" fill-opacity="0.12"/><polyline points="${pts}" fill="none" stroke="${col}" stroke-width="1.5" stroke-linejoin="round"/>${labels}${slices}</svg>`
517}
518// A hover readout at the top of a chart: a raised tag beside x, flipped left where it would run off.
519const HOVER_CSS = `<style>:root{color-scheme:dark;background:transparent}.h .k{opacity:0}.h:hover .k{opacity:1}</style>`
520const hoverTag = (x: number, w: number, tag: string) => {
521  const tw = tag.length * 6.6 + 12
522  const tx = x + 8 + tw > w ? x - 8 - tw : x + 8
523  return `<rect x="${tx.toFixed(1)}" y="4" width="${tw.toFixed(1)}" height="20" rx="4" fill="${AG.raise}" stroke="${AG.line}"/><text x="${(tx + 6).toFixed(1)}" y="18" fill="${AG.fg}" font-weight="600" ${AXIS_FONT}>${esc(tag)}</text>`
524}
525// The allocation colours: Agon's gold first, then hues apart from the green and red that mean up and
526// down; whatever is past the fifth holding is Others, in the dim grey.
527const SLICE = [AG.gold, '#6f9cf2', AG.chrome, AG.ember, '#9d7cd8']
528// Shares that sum to 1 as a pie, a wedge each clockwise from 12 o'clock. A plain image, not an
529// interactive frame, so it sits on the card's own background.
530const pieSvg = (slices: { share: number; color: string }[], size: number) => {
531  const c = size / 2
532  const r = c - 1
533  let a0 = -Math.PI / 2
534  const wedges = slices
535    .map(sl => {
536      if (sl.share >= 0.9999) return `<circle cx="${c}" cy="${c}" r="${r}" fill="${sl.color}"/>`
537      const a1 = a0 + sl.share * 2 * Math.PI
538      const [x0, y0, x1, y1] = [c + r * Math.cos(a0), c + r * Math.sin(a0), c + r * Math.cos(a1), c + r * Math.sin(a1)]
539      const d = `M${c},${c} L${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${sl.share > 0.5 ? 1 : 0} 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`
540      a0 = a1
541      return `<path d="${d}" fill="${sl.color}"/>`
542    })
543    .join('')
544  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${wedges}</svg>`
545}
546// Shares that sum to 1 as one rounded bar, a segment each with a hairline gap between. Drawn as a
547// plain image, not an interactive frame, so it sits on the card's own background.
548const allocBarSvg = (slices: { share: number; color: string }[], w: number, h = 10) => {
549  const gap = slices.length > 1 ? 2 : 0
550  const room = w - gap * (slices.length - 1)
551  let x = 0
552  const segs = slices
553    .map(sl => {
554      const sw = Math.max(2, sl.share * room)
555      const seg = `<rect x="${x.toFixed(1)}" y="0" width="${sw.toFixed(1)}" height="${h}" fill="${sl.color}"/>`
556      x += sw + gap
557      return seg
558    })
559    .join('')
560  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><defs><clipPath id="r"><rect width="${w}" height="${h}" rx="${h / 2}"/></clipPath></defs><g clip-path="url(#r)">${segs}</g></svg>`
561}
562// Jupiter's own 5m, 1h, 6h and 24h moves as bars: always there, whatever GeckoTerminal answers.
563const movesSvg = (moves: [string, number | null][], w: number, h: number) => {
564  const top = Math.max(1, ...moves.map(([, m]) => Math.abs(m ?? 0)))
565  const slot = w / moves.length
566  const mid = (h - 18) / 2
567  const bars = moves
568    .map(([label, m], i) => {
569      const v = m ?? 0
570      const len = (Math.abs(v) / top) * (mid - 2)
571      const y = v >= 0 ? mid - len : mid
572      const col = m == null || Math.abs(v) < 0.005 ? AG.dim : v > 0 ? AG.up : AG.down
573      const x = i * slot + slot * 0.3
574      return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${(slot * 0.4).toFixed(1)}" height="${Math.max(1, len).toFixed(1)}" fill="${col}"/><text x="${(i * slot + slot / 2).toFixed(1)}" y="${h - 3}" text-anchor="middle" fill="${AG.muted}" ${AXIS_FONT}>${label}</text>`
575    })
576    .join('')
577  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${mid}" x2="${w}" y2="${mid}" stroke="${AG.line}"/>${bars}</svg>`
578}
579// The terminal's logos. The sandbox has no inflate, so the proxy is asked for a PNG at zlib level 0,
580// whose image data sits in stored blocks: copied, then unfiltered row by row. RGB or RGBA, 8-bit,
581// not interlaced; anything else draws the symbol's first letter.
582const pixelsUrl = (url: string) => proxied(url).replace('&w=48&h=48', '&w=8&h=8') + '&l=0'
583function unpng(png: Uint8Array): Uint8Array | null {
584  const dv = new DataView(png.buffer, png.byteOffset, png.byteLength)
585  let p = 8
586  let w = 0
587  let h = 0
588  let ct = 0
589  const idat: number[] = []
590  while (p + 8 <= png.length) {
591    const len = dv.getUint32(p)
592    const type = String.fromCharCode(png[p + 4]!, png[p + 5]!, png[p + 6]!, png[p + 7]!)
593    if (type === 'IHDR') {
594      w = dv.getUint32(p + 8)
595      h = dv.getUint32(p + 12)
596      ct = png[p + 17]!
597      if (png[p + 16] !== 8 || png[p + 20] !== 0) return null
598    } else if (type === 'IDAT') for (let k = 0; k < len; k++) idat.push(png[p + 8 + k]!)
599    else if (type === 'IEND') break
600    p += 12 + len
601  }
602  if ((ct !== 2 && ct !== 6) || w === 0 || w > 64 || h > 64) return null
603  const raw: number[] = []
604  let q = 2
605  for (;;) {
606    const head = idat[q++]
607    if (head === undefined || ((head >> 1) & 3) !== 0) return null
608    const n = idat[q]! | (idat[q + 1]! << 8)
609    q += 4
610    for (let k = 0; k < n; k++) raw.push(idat[q + k]!)
611    q += n
612    if (head & 1) break
613  }
614  const bpp = ct === 6 ? 4 : 3
615  const stride = w * bpp
616  const out = new Uint8Array(w * h * 4)
617  let prev = new Uint8Array(stride)
618  for (let y = 0; y < h; y++) {
619    const f = raw[y * (stride + 1)]
620    const cur = new Uint8Array(stride)
621    for (let i = 0; i < stride; i++) {
622      const x = raw[y * (stride + 1) + 1 + i]!
623      const a = i >= bpp ? cur[i - bpp]! : 0
624      const b = prev[i]!
625      const c = i >= bpp ? prev[i - bpp]! : 0
626      const pa = Math.abs(b - c)
627      const pb = Math.abs(a - c)
628      const pc = Math.abs(a + b - 2 * c)
629      cur[i] =
630        (f === 0 ? x : f === 1 ? x + a : f === 2 ? x + b : f === 3 ? x + ((a + b) >> 1) : x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255
631    }
632    for (let x = 0; x < w; x++) {
633      out.set([cur[x * bpp]!, cur[x * bpp + 1]!, cur[x * bpp + 2]!, bpp === 4 ? cur[x * bpp + 3]! : 255], (y * w + x) * 4)
634    }
635    prev = cur
636  }
637  return out
638}
639// Half-block cells: each cell is 2 pixels tall, top in the foreground, bottom in the background.
640function halfBlocks(rgba: Uint8Array, cols: number, rows: number) {
641  const side = Math.round(Math.sqrt(rgba.length / 4))
642  const at = (x: number, y: number) => {
643    const o = (Math.floor((y * side) / (rows * 2)) * side + Math.floor((x * side) / cols)) * 4
644    return rgba[o + 3]! < 128 ? null : (rgba[o]! << 16) | (rgba[o + 1]! << 8) | rgba[o + 2]!
645  }
646  const words = new Uint32Array(cols * rows * 3)
647  for (let r = 0; r < rows; r++)
648    for (let c = 0; c < cols; c++) {
649      const top = at(c, r * 2)
650      const bottom = at(c, r * 2 + 1)
651      const i = (r * cols + c) * 3
652      const none = 0x01000000
653      if (top !== null) words.set([0x2580, top, bottom ?? none], i)
654      else if (bottom !== null) words.set([0x2584, bottom, none], i)
655      else words.set([0x20, none, none], i)
656    }
657  return new Uint8Array(words.buffer).toBase64()
658}
659// The terminal's chart: braille, 2 by 4 dots a cell.
660const DOT = [
661  [0x01, 0x08],
662  [0x02, 0x10],
663  [0x04, 0x20],
664  [0x40, 0x80],
665]
666const braille = (values: number[], w: number, h: number) => {
667  const grid = Array.from({ length: h }, () => Array.from({ length: w }, () => 0))
668  const lo = Math.min(...values)
669  const span = Math.max(...values) - lo || 1
670  let prev: number | null = null
671  for (let x = 0; x < w * 2; x++) {
672    const v = values[Math.round((x / (w * 2 - 1)) * (values.length - 1))]!
673    const y = Math.round((1 - (v - lo) / span) * (h * 4 - 1))
674    for (let yy = Math.min(prev ?? y, y); yy <= Math.max(prev ?? y, y); yy++)
675      grid[Math.floor(yy / 4)]![Math.floor(x / 2)]! |= DOT[yy % 4]![x % 2]!
676    prev = y
677  }
678  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
679}
680
681type Dollar = EngineInterfaceBuilt
682type Site = RenderInput<'AbovePrompt'> | RenderInput<'Pane'>
683
684// One option of a segmented control: the chosen one a real button, the others plain text, so the
685// choice reads by shape and not by colour alone, with no brackets drawn around it.
686// The others are dim at rest and turn gold under the pointer, each its own hover group.
687const segment = (Button: ElementConstructor<ButtonProps>, key: string, label: string, on: boolean, press: () => unknown) =>
688  on ? (
689    <Button key={key} variant="secondary" label={label} onPress={press} />
690  ) : (
691    <Button key={key} plain dimColor label={label} hover={{ scope: `seg-${key}`, color: AG.gold }} onPress={press} />
692  )
693
694// Every one-of-N choice in Agon draws through here, so each reads the same: the switch.tsx pill where
695// the surface draws a Client (terminal, desktop) and it fits `room`, else a row of segment buttons
696// under each option's own key (mobile, the editor). The pill posts { pick }; ui.message hands it to
697// the pick this drawing registered under the Client's key, after checking it is one of the options.
698type Opt = { key: string; label: string }
699const picks = new Map<string, { n: number; pick: (i: number) => unknown }>()
700// A click area over a card or row: an invisible press.tsx layer that opens its token in Trade. Its
701// height must be a count of rows: a percentage resolves to the layer's own content, 1 row, which
702// left most of each card dead (seen 2026-10-10). Desktop rows are about 20 px (measured that day in
703// the Claude desktop app at default zoom), so an Svg takes ceil(px / 20) rows.
704const ROW_PX = 20
705const svgRows = (px: number) => Math.ceil(px / ROW_PX)
706const pressTargets = new Map<string, Tok>()
707function pressArea(e: Site, $: Dollar, key: string, x: Tok, rows: number, at: { top?: number; right?: number } = {}) {
708  const els = $.ui.resolve(e)
709  pressTargets.set(key, x)
710  return (
711    <els.Box key={`pa-${key}`} position="absolute" top={at.top ?? 0} left={0} right={at.right ?? 0} height={rows}>
712      <els.Client key={key} module="./press.tsx" width="100%" height={rows} props={{}} />
713    </els.Box>
714  )
715}
716// Cells across, as the terminal counts them: one a code point.
717const cells = (s: string) => [...s].length
718// The pill's width, the same arithmetic switch.tsx draws by: each option its label and a cell of
719// padding either side, a cell between options, none when compact.
720const pillWidth = (opts: Opt[], compact = false, outlined = false) => opts.reduce((a, o) => a + cells(o.label) + 2 + (outlined ? 2 : 0), 0) + (compact ? 0 : opts.length - 1)
721function choice($: Dollar, e: Site, key: string, opts: Opt[], value: number, pick: (i: number) => unknown, room = Infinity, outlined = false) {
722  const els = $.ui.resolve(e)
723  // The full pill where it fits, the compact one where only that fits, else a row of buttons.
724  const compact = pillWidth(opts, false, outlined) > room
725  const width = pillWidth(opts, compact, outlined)
726  if ((e.surface === 'terminal' || e.surface === 'desktop') && width <= room) {
727    picks.set(key, { n: opts.length, pick })
728    return <els.Client key={key} module="./switch.tsx" width={width} height={outlined ? 3 : undefined} props={{ options: opts.map(o => o.label), value, surface: e.surface, compact, outlined }} />
729  }
730  return (
731    <els.Box key={key} flexDirection="row" columnGap={1} rowGap={1} alignItems="center" flexWrap="wrap">
732      {opts.map((o, i) => segment(els.Button, o.key, o.label, i === value, () => pick(i)))}
733    </els.Box>
734  )
735}
736
737// Agon's layout, in cells. Spacing: 1 inside a group (a label and its control, a pill's options), 2
738// between groups and columns; rows: none inside a group, 1 between groups and between the lines of
739// controls that wrap. Lists, cards and reading text stop at CAP cells, left-aligned with the rest
740// left empty, so a wide pane keeps a name beside its numbers. An inner card keeps its border only
741// from ROOMY cells; narrower, the page frame is the one frame and sections part by space and a rule.
742const CAP = 96
743const ROOMY = 72
744// The page frame on the desktop: its border, 2 cells inside on the left and 3 on the right, the third
745// kept clear of the scrollbar. The terminal frames a pane itself, so there only a cell either side.
746const framed = (e: Site) => e.surface !== 'terminal'
747const padL = (e: Site) => (framed(e) ? 2 : 1)
748const padR = (e: Site) => (framed(e) ? 3 : 1)
749const frameCols = (e: Site) => (framed(e) ? 2 : 0) + padL(e) + padR(e)
750const bodyCols = (e: Site) => (e.props as { bodyColumns?: number }).bodyColumns ?? 80
751// The cells a view has inside the page frame; the band has no frame, only its cell either side.
752const innerCols = (e: Site, band = false) => Math.max(24, bodyCols(e) - (band ? 2 : frameCols(e)))
753// A Button's cells: the terminal draws `[ label ]` (a plain one the label alone); the desktop draws a
754// native button with padding of its own either way.
755const buttonCols = (e: Site, label: string, plain = false) => cells(label) + (e.surface === 'terminal' ? (plain ? 0 : 4) : plain ? 2 : 4)
756// A desktop cell is about 7 px of the app font: an Svg drawn n cells wide is n * PX px, less a cell.
757const PX = 7
758const svgPx = (cols: number) => Math.max(PX, cols * PX - PX)
759
760// A section of a view: a rounded card where there is room for one, else the same content with no
761// border, so a narrow pane draws one frame. Returns the element and the cells its content has.
762function section($: Dollar, e: Site, key: string, width: number, children: unknown, border: string = AG.line) {
763  const { Box } = $.ui.resolve(e)
764  const boxed = width >= ROOMY
765  return (
766    <Box key={key} flexDirection="column" rowGap={1} width={width} {...(boxed ? { borderStyle: 'round', borderColor: border, paddingX: 1 } : {})}>
767      {children as JSX.Element}
768    </Box>
769  )
770}
771const sectionInner = (width: number) => (width >= ROOMY ? width - 4 : width)
772
773// A hairline between groups: a run of rule glyphs on the terminal, a 1 px line on the desktop, where
774// a run of glyphs draws wider than its cells.
775function hairline($: Dollar, e: Site, key: string, width: number) {
776  const els = $.ui.resolve(e)
777  if (e.surface === 'terminal') return <els.Text key={key} color={AG.line}>{'─'.repeat(Math.max(1, width))}</els.Text>
778  const w = svgPx(width)
779  return (
780    <els.Box key={key} width={width} height={1} alignItems="center">
781      <els.Svg key={`${key}-svg`} source={`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="1" viewBox="0 0 ${w} 1"><rect width="${w}" height="1" fill="${AG.line}"/></svg>`} alt="divider" width={w} height={1} />
782    </els.Box>
783  )
784}
785
786// Agon's views, all in one pane: discovery, the watchlist, one token to trade, the bots and the
787// portfolio, with the start in front on first run. The nav row on top of the pane switches between
788// them; with one pane open, Claude Code draws no tab bar of its own.
789const NAV = [
790  { label: 'Start', view: 'start' },
791  { label: 'Discover', view: 'discover' },
792  { label: 'Watchlist', view: 'watch' },
793  { label: 'Trade', view: 'trade' },
794  { label: 'Bots', view: 'bots' },
795  { label: 'Portfolio', view: 'portfolio' },
796] as const
797type View = (typeof NAV)[number]['view']
798const PANE_TITLE = 'Agon'
799// Switches the pane's view; the pane is already on screen when its own nav row is pressed.
800const showView = ($: Dollar, view: View) => $.state.set(PANE_VIEW, view)
801// Switches the view and opens or focuses the pane, from the band, a command or another view's button.
802const goTo = async ($: Dollar, view: View) => {
803  await showView($, view)
804  return $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
805}
806
807// A token as the model may read it: its symbol only when the mint is on the pinned list.
808const tokenRef = (mint: string) => (!BASE58.test(mint) ? 'a token with no valid mint' : PINNED[mint] ? `${PINNED[mint]} (mint ${mint})` : `the token with mint ${mint}`)
809
810// The last 5 things the person did, in the plugin's own words: each built from fixed text, mints
811// and numbers, never from a token's name, so it can go to the model with the view.
812async function noteAction($: Dollar, text: string) {
813  await $.state.set(RECENT, [...((await $.state.get(RECENT)).value ?? []), text].slice(-5))
814}
815
816// A token picked in Discover or the watchlist opens in Trade; its chart loads as the tab shows. The
817// token is kept, so one found by search stays on show after the search moves on.
818async function openTrade($: Dollar, x: Tok) {
819  void noteAction($, `opened ${tokenRef(x.mint)} in Trade`)
820  await $.state.set(PICKED, x)
821  await goTo($, 'trade')
822  await select($, x.mint)
823  void loadDepth($, x)
824}
825
826// Jupiter's own search, by name, symbol or mint, across every token: the list on show holds 50,
827// and "solana" matched none of their symbols. Typing waits for a 300 ms pause, Enter searches at
828// once, and only the newest query's answer is kept.
829let searchSeq = 0
830async function runSearch($: Dollar, raw: string, seq: number) {
831  const q = raw.trim().slice(0, 64)
832  if (seq !== searchSeq) return
833  if (q.length < 2) return $.state.set(SEARCH, null)
834  await $.state.set(SEARCH, { query: q, tokens: [], error: null, done: false })
835  try {
836    const tokens = await getTokens($, `${JUP}/search?query=${encodeURIComponent(q)}`)
837    if (seq !== searchSeq) return
838    await $.state.set(SEARCH, { query: q, tokens, error: null, done: true })
839    void loadLogos($, tokens.slice(0, 20))
840  } catch (err) {
841    if (seq === searchSeq) await $.state.set(SEARCH, { query: q, tokens: [], error: why(err), done: true })
842  }
843}
844function queueSearch($: Dollar, raw: string) {
845  const seq = ++searchSeq
846  const run = () => void runSearch($, raw, seq)
847  Promise.resolve()
848    .then(() => $.clock.after(300, run))
849    .catch(run)
850}
851
852// Each pane's sections and the lane each starts in, until the person moves them.
853const LAYOUT_DEFAULTS: Record<'bots', PaneLayout> = {
854  bots: { main: ['list', 'builder'], side: ['detail'], hidden: [], dense: false },
855}
856const SECTION_NAMES: Record<string, string> = { list: 'Bots', builder: 'New bot', detail: 'Details' }
857
858// A pane's saved layout, read defensively: an unknown section is dropped and a missing one returns
859// to its default place in its default lane, so a section added later (the watchlist) shows where it
860// was designed to, not below a 50-row table, and an old or hand-edited store never loses one.
861const layoutOf = (all: Record<string, PaneLayout> | null | undefined, pane: 'bots'): PaneLayout => {
862  const d = LAYOUT_DEFAULTS[pane]
863  const l = all?.[pane]
864  if (!l || typeof l !== 'object') return d
865  const known = [...d.main, ...d.side]
866  const ids = (xs: unknown) => (Array.isArray(xs) ? xs.filter((x): x is string => known.includes(x)) : [])
867  const main = ids(l.main)
868  const side = ids(l.side).filter(x => !main.includes(x))
869  const lost = known.filter(x => !main.includes(x) && !side.includes(x))
870  const back = (lane: string[], def: string[]) => {
871    const out = [...lane]
872    for (const id of lost.filter(x => def.includes(x))) out.splice(Math.min(def.indexOf(id), out.length), 0, id)
873    return out
874  }
875  return { main: back(main, d.main), side: back(side, d.side), hidden: ids(l.hidden), dense: l.dense === true }
876}
877const toggleHidden = (l: PaneLayout, id: string): PaneLayout => ({ ...l, hidden: l.hidden.includes(id) ? l.hidden.filter(x => x !== id) : [...l.hidden, id] })
878async function saveLayout($: Dollar, pane: 'bots', l: PaneLayout) {
879  const all = { ...((await $.state.get(LAYOUTS)).value ?? {}), [pane]: l }
880  await $.state.set(LAYOUTS, all)
881  await $.store.set('layouts', all)
882}
883
884// Lays a pane's sections out by its layout: two columns when `split` and both lanes show
885// something, else one column with the side lane on top, the selected item above its list.
886function arrange(Box: ElementConstructor<BoxProps>, l: PaneLayout, parts: Record<string, JSX.Element | null | undefined | false | ''>, split: boolean, sideWidth: number) {
887  const gap = l.dense ? 0 : 1
888  const show = (ids: string[]) => ids.filter(id => !l.hidden.includes(id) && parts[id]).map(id => parts[id])
889  const main = show(l.main)
890  const side = show(l.side)
891  return split && main.length > 0 && side.length > 0 ? (
892    <Box key="lanes" flexDirection="row" gap={2}>
893      <Box flexDirection="column" gap={gap} flexGrow={1} minWidth={0}>{main}</Box>
894      <Box flexDirection="column" gap={gap} width={sideWidth}>{side}</Box>
895    </Box>
896  ) : (
897    <Box key="lanes" flexDirection="column" gap={gap}>{[...side, ...main]}</Box>
898  )
899}
900
901// The layout bar under a pane's header while the person edits that pane's layout: a drag bar where
902// the surface draws a Client (terminal, desktop), buttons where it does not (mobile, the editor).
903function layoutBar($: Dollar, e: Site, pane: 'bots', l: PaneLayout, width: number) {
904  const els = $.ui.resolve(e)
905  const { Box, Text, Button } = els
906  const seq = [...l.main, '|', ...l.side]
907  const move = (id: string, by: number) => {
908    const i = seq.indexOf(id)
909    const j = i + by
910    if (j < 0 || j >= seq.length) return
911    const next = [...seq]
912    next[i] = next[j]!
913    next[j] = id
914    const cut = next.indexOf('|')
915    return saveLayout($, pane, { ...l, main: next.slice(0, cut), side: next.slice(cut + 1) })
916  }
917  const tiles = (ids: string[]) => ids.map(id => ({ id, label: SECTION_NAMES[id] ?? id }))
918  return section($, e, 'layout', width, [
919    <Box key="lay-tiles" flexDirection="column">
920      <Text bold color={AG.gold}>
921        Layout
922      </Text>
923      {e.surface === 'terminal' || e.surface === 'desktop' ? (
924        <Box flexDirection="column">
925          <els.Client key={`lay-${pane}`} module="./layout.tsx" width="100%" props={{ main: tiles(l.main), side: tiles(l.side), hidden: l.hidden }} />
926          <Text color={AG.dim} wrap="wrap">
927            Drag a tile to move it, click it to hide or show it
928          </Text>
929        </Box>
930      ) : (
931        seq
932          .filter(id => id !== '|')
933          .map(id => (
934            <Box key={`lo-${id}`} flexDirection="row" columnGap={1} alignItems="center">
935              <Box width={9} flexShrink={0}>
936                <Text strikethrough={l.hidden.includes(id)} color={l.hidden.includes(id) ? AG.dim : undefined}>
937                  {SECTION_NAMES[id] ?? id}
938                </Text>
939              </Box>
940              <Box width={6} flexShrink={0}>
941                <Text color={AG.dim}>{l.side.includes(id) ? 'right' : 'left'}</Text>
942              </Box>
943              <Button key={`lo-up-${id}`} plain label="◂" onPress={() => move(id, -1)} />
944              <Button key={`lo-dn-${id}`} plain label="▸" onPress={() => move(id, 1)} />
945              <Button key={`lo-hd-${id}`} plain dimColor label={l.hidden.includes(id) ? 'Show' : 'Hide'} onPress={() => saveLayout($, pane, toggleHidden(l, id))} />
946            </Box>
947          ))
948      )}
949    </Box>,
950    <Box key="lay-density" flexDirection="row" columnGap={1} alignItems="center">
951      <Box width={8} flexShrink={0}>
952        <Text color={AG.dim}>Density</Text>
953      </Box>
954      {choice(
955        $,
956        e,
957        `density-${pane}`,
958        [
959          { key: 'dn-comfy', label: 'Comfortable' },
960          { key: 'dn-dense', label: 'Compact' },
961        ],
962        l.dense ? 1 : 0,
963        i => saveLayout($, pane, { ...l, dense: i === 1 }),
964        sectionInner(width) - 9,
965      )}
966    </Box>,
967    <Box key="lay-acts" flexDirection="row" columnGap={2} rowGap={1} alignItems="center" flexWrap="wrap">
968      <Button key="lo-done" label="✓ Done" onPress={() => $.state.set(LAYOUT_EDIT, null)} />
969      <Button key="lo-reset" plain dimColor label="⟲ Reset" onPress={() => saveLayout($, pane, LAYOUT_DEFAULTS[pane])} />
970    </Box>,
971  ], AG.gold)
972}
973
974// Jupiter answers keyless at lite-api, or at api.jup.ag with a key and more room. Keys sit one per
975// line in ~/.agon/jupiter-keys, outside every repository, read once per load and never shown or
976// logged; a line that is not a Jupiter key is skipped. Each call takes the next key in turn, and one
977// that answers 429 (rate limited) or 401/403 (refused) hands the call to the next. With no keys, or
978// every key refused, the call goes keyless as before; a 429 from every key is returned as it is.
979const JUP_LITE = 'https://lite-api.jup.ag/'
980let jupKeys: Promise<string[]> | null = null
981let jupTurn = 0
982const readJupKeys = async ($: Dollar) => {
983  try {
984    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
985    if (!home) return []
986    const text = await $.fs.read(`${home}/.agon/jupiter-keys`)
987    return [...new Set(text.split(/\r?\n/).map(l => l.trim()).filter(l => /^jup_[0-9a-f]{64}$/.test(l)))]
988  } catch {
989    return []
990  }
991}
992async function jupFetch($: Dollar, url: string) {
993  const keys = url.startsWith(JUP_LITE) ? await (jupKeys ??= readJupKeys($)) : []
994  let last: Awaited<ReturnType<Dollar['http']['fetch']>> | null = null
995  for (let i = 0; i < keys.length; i++) {
996    last = await $.http.fetch(`https://api.jup.ag/${url.slice(JUP_LITE.length)}`, { headers: { 'x-api-key': keys[jupTurn++ % keys.length]! } })
997    if (last.status !== 429 && last.status !== 401 && last.status !== 403) {
998      jupHost = 'api.jup.ag'
999      return last
1000    }
1001  }
1002  if (last && last.status === 429) return last
1003  jupHost = new URL(url).host
1004  return $.http.fetch(url)
1005}
1006// The Jupiter host the last direct call went to, for the source line.
1007let jupHost = 'lite-api.jup.ag'
1008
1009async function getTokens($: Dollar, url: string) {
1010  const res = await jupFetch($, url)
1011  if (!res.ok) throw new Error(`Jupiter answered ${res.status}`)
1012  const body = JSON.parse(res.text) as unknown
1013  if (!Array.isArray(body)) throw new Error('Jupiter answered something that is not a token list')
1014  return body.map(t => toTok(t as Record<string, any>)).filter(t => BASE58.test(t.mint))
1015}
1016
1017// Market data asks the Agon server first (agon-dev T-C53): its /price, /tokens, /quote and /candles
1018// read Jupiter and GeckoTerminal once for every client from a shared cache and its own key pool, so
1019// 100 people on one token cost 1 upstream call. Nothing answering, a 5xx, a 404 (a server from before
1020// those routes) or an answer this cannot read sends that read straight upstream as before (jupFetch's
1021// keys, then keyless), and the server is asked again after SERVER_REST_MS. A 429 is the server's limit
1022// of 120 requests a minute per caller: its Retry-After is waited out and nothing goes to Jupiter
1023// meanwhile, so a limited plugin never turns into a plugin hammering Jupiter instead. A 400 is this
1024// plugin's mistake and is shown, never retried elsewhere.
1025const SERVER_REST_MS = 30_000
1026// The engine's clock, so a test moves it; a host with no clock answers the wall clock.
1027const clockNow = async ($: Dollar) => {
1028  try {
1029    return await $.clock.now()
1030  } catch {
1031    return Date.now()
1032  }
1033}
1034let serverWaitUntil = 0
1035// Per route, so a route whose upstream fails (candles on GeckoTerminal) leaves the others on the server.
1036const serverDown = new Map<string, { until: number; why: string }>()
1037class ServerBusy extends Error {}
1038const retryAfterS = (h: string | undefined) => {
1039  const v = (h ?? '').trim()
1040  if (/^\d+$/.test(v)) return Math.max(1, Number(v))
1041  const at = Date.parse(v)
1042  return Number.isFinite(at) ? Math.max(1, Math.ceil((at - Date.now()) / 1000)) : 60
1043}
1044const busyText = (s: number) => `the Agon server is over its 120 requests a minute and asked for ${s} s of quiet, so nothing is asked of it or of Jupiter until then`
1045// One read: `read` turns the server's answer into the value, or undefined when it cannot (then the
1046// read goes direct); it may throw a cause the person should see. `direct` is the old upstream path.
1047async function viaServer<T>(
1048  $: Dollar,
1049  path: string,
1050  read: (body: any, status: number) => T | undefined,
1051  direct: () => Promise<{ value: T; host: string }>,
1052): Promise<{ value: T; src: Src }> {
1053  const now = await clockNow($)
1054  if (now < serverWaitUntil) throw new ServerBusy(busyText(Math.ceil((serverWaitUntil - now) / 1000)))
1055  const route = path.split('?')[0]!
1056  let down = serverDown.get(route)
1057  if (!down || now >= down.until) {
1058    let res: Awaited<ReturnType<Dollar['http']['fetch']>> | null = null
1059    let downWhy = ''
1060    try {
1061      res = await $.http.fetch(`${MCP}${path}`)
1062    } catch (err) {
1063      // The reason in a few words: the full error repeats the whole URL, every mint in it.
1064      downWhy = `nothing answers at ${MCP} (${(why(err).match(/E[A-Z]{4,}|timed out|aborted/) ?? ['no answer'])[0]})`
1065    }
1066    if (res) {
1067      if (res.status === 429) {
1068        const s = retryAfterS(res.headers['retry-after'])
1069        serverWaitUntil = now + s * 1000
1070        throw new ServerBusy(busyText(s))
1071      }
1072      let body: any
1073      try {
1074        body = JSON.parse(res.text)
1075      } catch {
1076        body = undefined
1077      }
1078      const said = typeof body?.error === 'string' ? printable(body.error, 200) : null
1079      const value = res.status === 200 || res.status >= 500 || res.status === 400 ? read(body, res.status) : undefined
1080      if (value !== undefined) {
1081        const s = body?.source
1082        const age = num(s?.ageSeconds) ?? 0
1083        return {
1084          value,
1085          src: {
1086            via: 'agon',
1087            host: typeof s?.host === 'string' ? printable(s.host, 40) : typeof s === 'string' ? printable(s.split(',')[0]!, 40) : MCP.replace(/^https?:\/\//, ''),
1088            at: Date.now() - age * 1000,
1089            stale: body?.stale === true ? printable(String(body.staleReason ?? `served from ${num(body.ageSeconds) ?? '?'} s ago`), 200) : null,
1090            why: null,
1091          },
1092        }
1093      }
1094      if (res.status === 400) throw new Error(`the Agon server refused ${route}: ${said ?? 'no reason given'}`)
1095      downWhy = `the Agon server at ${MCP} answered ${res.status}${said ? ` (${said})` : res.status === 200 ? ' with nothing Agon can read' : ''}`
1096    }
1097    down = { until: now + SERVER_REST_MS, why: downWhy }
1098    serverDown.set(route, down)
1099  }
1100  const got = await direct()
1101  return { value: got.value, src: { via: 'direct', host: got.host, at: Date.now(), stale: null, why: down.why } }
1102}
1103
1104// How a source reads where prices are shown: short in the band, whole in the panes.
1105const ago = (ms: number) => {
1106  const s = Math.max(0, Math.round(ms / 1000))
1107  return s < 120 ? `${s} s ago` : `${Math.round(s / 60)} min ago`
1108}
1109const srcText = (s: Src, short = false) =>
1110  s.via === 'agon'
1111    ? `${short ? 'via Agon' : `via Agon server from ${s.host}`}, ${ago(Date.now() - s.at)}${s.stale ? (short ? ', stale' : `, stale: ${s.stale}`) : ''}`
1112    : `direct from ${/jup\.ag$/.test(s.host) ? 'Jupiter' : s.host}${short ? '' : ` (${s.host})${s.why ? `, since ${s.why}` : ''}`}`
1113
1114// Token info for mints, 50 to a call: the server's /tokens, else Jupiter's search by mint.
1115async function tokenInfo($: Dollar, mints: string[]): Promise<{ toks: Tok[]; src: Src | null }> {
1116  const ids = [...new Set(mints.filter(m => BASE58.test(m)))]
1117  const toks: Tok[] = []
1118  let src: Src | null = null
1119  for (let i = 0; i < ids.length; i += 50) {
1120    const part = ids.slice(i, i + 50)
1121    const got = await viaServer(
1122      $,
1123      `/tokens?ids=${part.join(',')}`,
1124      body => (Array.isArray(body?.tokens) ? (body.tokens as Record<string, any>[]).flatMap(r => rowTok(r) ?? []) : undefined),
1125      async () => ({ value: await getTokens($, `${JUP}/search?query=${part.join(',')}`), host: jupHost }),
1126    )
1127    toks.push(...got.value)
1128    src = got.src
1129  }
1130  return { toks, src }
1131}
1132
1133// The price poll: one /price call for every mint on screen (the watchlist, the token in Trade) and
1134// every bot's, each PRICE_MS. A bot tick within PRICE_FRESH_MS of a poll that priced its mint reads
1135// that poll instead of asking again, so the band and the bots share one call a tick.
1136const PRICE_MS = 10_000
1137const PRICE_FRESH_MS = 5_000
1138type Poll = { at: number; mints: Set<string>; prices: Map<string, Live>; src: Src | null }
1139let lastPoll: Poll | null = null
1140let polling: Promise<unknown> | null = null
1141async function pricedMints($: Dollar) {
1142  const botMints = ((await $.state.get(BOTS)).value ?? []).filter(b => b.status === 'running' || b.book.held !== '0').map(b => b.mint)
1143  const trade = tradeTok && Date.now() - tradeSeenAt < 60_000 ? [tradeTok.mint] : []
1144  return [...new Set([...((await $.state.get(WATCHLIST)).value ?? WATCH), ...trade, ...botMints])].filter(m => BASE58.test(m)).slice(0, 50)
1145}
1146async function pollPrices($: Dollar, need: string[] = [], force = false): Promise<Poll> {
1147  while (polling) await polling.catch(() => undefined)
1148  const now = await clockNow($)
1149  const mints = [...new Set([...need, ...(await pricedMints($))])].slice(0, 50)
1150  if (!force && lastPoll && now - lastPoll.at < PRICE_FRESH_MS && mints.every(m => lastPoll!.mints.has(m))) return lastPoll
1151  if (!mints.length) return { at: now, mints: new Set(), prices: new Map(), src: null }
1152  const run = (async () => {
1153    const prev = (await $.state.get(PRICE_FEED)).value ?? null
1154    try {
1155      const got = await viaServer(
1156        $,
1157        `/price?ids=${mints.join(',')}`,
1158        body => {
1159          if (!body?.prices || typeof body.prices !== 'object' || Array.isArray(body.prices)) return undefined
1160          const m = new Map<string, Live>()
1161          for (const mint of mints) {
1162            const p = body.prices[mint]
1163            const price = num(p?.usdPrice)
1164            if (price != null && price > 0)
1165              m.set(mint, { price, change24h: num(p.priceChange24h), liquidity: num(p.liquidity), decimals: Number.isInteger(p.decimals) ? p.decimals : null })
1166          }
1167          return m
1168        },
1169        async () => {
1170          const toks = await getTokens($, `${JUP}/search?query=${mints.join(',')}`)
1171          const m = new Map<string, Live>()
1172          for (const t of toks) if (mints.includes(t.mint) && t.price != null && t.price > 0) m.set(t.mint, { price: t.price, change24h: t.change24h, liquidity: t.liquidity, decimals: t.decimals })
1173          return { value: m, host: jupHost }
1174        },
1175      )
1176      const poll: Poll = { at: now, mints: new Set(mints), prices: got.value, src: got.src }
1177      lastPoll = poll
1178      await $.state.set(PRICE_FEED, { prices: { ...(prev?.prices ?? {}), ...Object.fromEntries(got.value) }, src: got.src, error: null })
1179      // The watched tokens carry the new prices, so the band and the watchlist draw them.
1180      const w = (await $.state.get(WATCHED)).value ?? []
1181      if (w.some(t => got.value.has(t.mint)))
1182        await $.state.set(
1183          WATCHED,
1184          w.map(t => {
1185            const l = got.value.get(t.mint)
1186            return l ? { ...t, price: l.price, change24h: l.change24h ?? t.change24h, liquidity: l.liquidity ?? t.liquidity } : t
1187          }),
1188        )
1189      return poll
1190    } catch (err) {
1191      await $.state.set(PRICE_FEED, { prices: prev?.prices ?? {}, src: prev?.src ?? null, error: why(err) })
1192      throw err
1193    }
1194  })()
1195  polling = run
1196  try {
1197    return await run
1198  } finally {
1199    if (polling === run) polling = null
1200  }
hooks/paper-bots.ts 156 lines
1// The paper bots' rules and books: plain data in, a decision or a new bot out, no network and no
2// clock of their own. register.tsx runs them on a timer against live Jupiter prices and sends what
3// they decide to the Agon server's paper_trade. Money is in base units, as BigInt.
4
5import type { Bot, BotBook, BotEvent, BotFill } from '../types'
6
7// Thousands grouped in the integer part alone, as a terminal shows money: 1234.5 reads 1,234.5.
8export const grouped = (n: string) => n.replace(/^(-?\d+)/, m => m.replace(/\B(?=(\d{3})+$)/g, ','))
9// Under half a cent reads $0.00, never -$0.00.
10export const usd = (v: number) => `${v <= -0.005 ? '-' : ''}$${grouped(Math.abs(v).toFixed(2))}`
11
12// A bot asks for live prices on this beat, one Jupiter call for every bot's token together.
13export const TICK_MS = 30_000
14// No order under 1 USDC: what is left of a budget below it is left unspent.
15export const MIN_ORDER = 1_000_000n
16export const FILLS_KEPT = 50
17export const EVENTS_KEPT = 20
18// After a refusal or an error the bot waits 2, 4, 8, 16 then 30 minutes before it asks again.
19export const backoffMs = (strikes: number) => Math.min(30 * 60_000, 2 * 60_000 * 2 ** Math.max(0, strikes - 1))
20// Buys refused this many times in a row pause the bot; a sell never pauses, it keeps its stop.
21export const PAUSE_AFTER = 3
22
23const USDC_ONE = 1_000_000n
24export const microOf = (dollars: number) => BigInt(Math.round(dollars)) * USDC_ONE
25const big = (s: string) => BigInt(s)
26const minutes = (ms: number) => Math.max(1, Math.ceil(ms / 60_000))
27
28export const newBook = (): BotBook => ({ spent: '0', received: '0', held: '0', cost: '0', realized: '0', buys: 0, sells: 0 })
29
30// One fill on the books, at average cost: a buy adds its units and what it cost; a sell takes its
31// share of the cost off (all of it when it sells all) and realizes what it got against that share.
32export function applyFill(b: BotBook, side: 'buy' | 'sell', usdc: bigint, units: bigint): BotBook {
33  if (side === 'buy')
34    return { ...b, spent: String(big(b.spent) + usdc), held: String(big(b.held) + units), cost: String(big(b.cost) + usdc), buys: b.buys + 1 }
35  const held = big(b.held)
36  const q = units > held ? held : units
37  const off = held > 0n ? (big(b.cost) * q) / held : 0n
38  return {
39    ...b,
40    received: String(big(b.received) + usdc),
41    held: String(held - q),
42    cost: String(big(b.cost) - off),
43    realized: String(big(b.realized) + usdc - off),
44    sells: b.sells + 1,
45  }
46}
47
48// USDC per whole token, from base units.
49export const priceOf = (usdc: bigint, units: bigint, decimals: number) => (units > 0n ? Number(usdc) / 1e6 / (Number(units) / 10 ** decimals) : 0)
50
51// The books valued at a live price: realized is exact, unrealized is what is held at that price
52// against its cost. With units held and no price, unrealized and total are unknown (null).
53export function pnlOf(b: BotBook, decimals: number, price: number | null, budget: number) {
54  const realized = Number(big(b.realized)) / 1e6
55  const held = big(b.held)
56  const value = held === 0n ? 0 : price == null ? null : (Number(held) / 10 ** decimals) * price
57  const unrealized = value == null ? null : held === 0n ? 0 : value - Number(big(b.cost)) / 1e6
58  const total = unrealized == null ? null : realized + unrealized
59  return { realized, unrealized, total, roi: total == null || budget <= 0 ? null : (total / budget) * 100, value }
60}
61
62// A DCA slice: the budget over the orders, never past the cap.
63export const sliceOf = (b: Bot) => {
64  const even = microOf(b.budget) / BigInt(Math.max(1, b.p2))
65  const cap = microOf(b.cap)
66  return even < cap ? even : cap
67}
68
69export type Decision = { kind: 'order'; side: 'buy' | 'sell'; amount: bigint; why: string } | { kind: 'wait'; why: string } | { kind: 'done'; why: string }
70
71// What the rule says at this price and time. Pure arithmetic on the bot's own books.
72export function decide(b: Bot, price: number, now: number): Decision {
73  const held = big(b.book.held)
74  if (b.strategy === 'dca') {
75    const budget = microOf(b.budget)
76    const left = budget - big(b.book.spent)
77    if (left < MIN_ORDER) return { kind: 'done', why: `budget spent: ${usd(Number(big(b.book.spent)) / 1e6)} of ${usd(b.budget)}` }
78    if (now < b.retryAt) return { kind: 'wait', why: waitWords(b, now) }
79    if (now < b.nextAt) return { kind: 'wait', why: `next slice in ${minutes(b.nextAt - now)} min` }
80    const slice = sliceOf(b)
81    const of = Number((budget + slice - 1n) / slice)
82    return { kind: 'order', side: 'buy', amount: left < slice ? left : slice, why: `slice ${b.book.buys + 1} of ${of}` }
83  }
84  if (held === 0n && b.book.buys > 0) return { kind: 'done', why: 'sold out after its exit' }
85  if (now < b.retryAt) return { kind: 'wait', why: waitWords(b, now) }
86  if (held === 0n) {
87    const budget = microOf(b.budget)
88    const cap = microOf(b.cap)
89    return { kind: 'order', side: 'buy', amount: budget < cap ? budget : cap, why: 'entry' }
90  }
91  const entry = priceOf(big(b.book.cost), held, b.decimals)
92  const peak = Math.max(b.peak ?? price, price)
93  if (price <= (entry * (100 - b.p2)) / 100)
94    return { kind: 'order', side: 'sell', amount: held, why: `hard stop: ${usd(price)} is ${((1 - price / entry) * 100).toFixed(1)}% under the ${usd(entry)} entry` }
95  if (price <= (peak * (100 - b.p1)) / 100)
96    return { kind: 'order', side: 'sell', amount: held, why: `trail: ${usd(price)} is ${((1 - price / peak) * 100).toFixed(1)}% off the ${usd(peak)} peak` }
97  return { kind: 'wait', why: `holding; exits at ${usd(Math.max((peak * (100 - b.p1)) / 100, (entry * (100 - b.p2)) / 100))}` }
98}
99
100const waitWords = (b: Bot, now: number) => `asks again in ${minutes(b.retryAt - now)} min, after ${b.strikes} ${b.strikes === 1 ? 'refusal' : 'refusals'}`
101
102// A fill the server recorded, on the bot: its books, its last fills, the next slice's time, the peak
103// a trail starts from, and done once the rule has nothing left to do.
104export function recordFill(
105  b: Bot,
106  f: { side: 'buy' | 'sell'; usdc: bigint; units: bigint; slot: number | null; stamp: string; why: string },
107  now: number,
108  price: number,
109): Bot {
110  const book = applyFill(b.book, f.side, f.usdc, f.units)
111  const fill: BotFill = { at: now, side: f.side, usdc: String(f.usdc), units: String(f.units), price: priceOf(f.usdc, f.units, b.decimals), slot: f.slot, stamp: f.stamp, why: f.why }
112  const next: Bot = { ...b, book, fills: [...b.fills, fill].slice(-FILLS_KEPT), strikes: 0, retryAt: 0, why: null, nextAt: now + b.p1 * 60_000 }
113  if (b.strategy === 'trail') next.peak = f.side === 'buy' ? price : null
114  const done = b.strategy === 'dca' ? microOf(b.budget) - big(book.spent) < MIN_ORDER : f.side === 'sell' && big(book.held) === 0n
115  return done ? { ...next, status: 'done' } : next
116}
117
118// A refusal or an error: kept with its reasons, and the bot waits longer each time before it asks
119// again. Buys refused PAUSE_AFTER times in a row pause it, with why.
120export function recordMiss(b: Bot, side: 'buy' | 'sell', ev: Omit<BotEvent, 'at'>, now: number): Bot {
121  const strikes = b.strikes + 1
122  const next: Bot = { ...b, events: [...b.events, { ...ev, at: now }].slice(-EVENTS_KEPT), strikes, retryAt: now + backoffMs(strikes) }
123  return side === 'buy' && strikes >= PAUSE_AFTER
124    ? { ...next, status: 'paused', why: `Paused after ${strikes} refusals in a row. Read the last reason, change the bot, then resume it.` }
125    : next
126}
127
128// A bot read back from the store, checked field by field; anything else (an old demo bot) is dropped.
129const isDigits = (v: unknown, signed = false) => typeof v === 'string' && (signed ? /^-?\d{1,30}$/ : /^\d{1,30}$/).test(v)
130const isNum = (v: unknown) => typeof v === 'number' && Number.isFinite(v)
131export function isBot(v: unknown): v is Bot {
132  if (typeof v !== 'object' || v === null) return false
133  const b = v as Record<string, unknown>
134  const book = b.book as Record<string, unknown> | undefined
135  return (
136    (b.strategy === 'dca' || b.strategy === 'trail') &&
137    typeof b.id === 'string' &&
138    typeof b.mint === 'string' &&
139    /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(b.mint) &&
140    typeof b.symbol === 'string' &&
141    (b.status === 'running' || b.status === 'paused' || b.status === 'done') &&
142    [b.budget, b.p1, b.p2, b.cap, b.created, b.decimals, b.nextAt, b.retryAt, b.strikes].every(isNum) &&
143    typeof book === 'object' &&
144    book !== null &&
145    isDigits(book.spent) &&
146    isDigits(book.received) &&
147    isDigits(book.held) &&
148    isDigits(book.cost) &&
149    isDigits(book.realized, true) &&
150    isNum(book.buys) &&
151    isNum(book.sells) &&
152    Array.isArray(b.fills) &&
153    Array.isArray(b.events)
154  )
155}
156
hooks/press.tsx 21 lines
1// An invisible press area laid over a card's body. A click, or Enter or space while it holds the
2// focus, opens the card; `m` asks for the card's menu. Not right-click: the desktop app opens its
3// own Copy menu on a right-click and a plugin cannot stop it (checked 2026-10-10). A hold to open the
4// menu made every click wait for the release, so the click fires on press again.
5
6import type { ClientModule } from 'claude-code'
7
8const Press: ClientModule<Record<string, never>, null> = (_p, s) => {
9  const { Box } = s.elements
10  s.onPointer(ev => {
11    if (ev.type === 'down' && ev.button === 'left') s.post({ press: true })
12  })
13  s.onKey(k => {
14    if (k.key === 'return' || k.key === ' ') s.post({ press: true })
15    else if (k.key === 'm') s.post({ menu: true })
16  })
17  return Box({ width: '100%', height: '100%' })
18}
19
20export default Press
21
hooks/switch.tsx 73 lines
1// Agon's one-of-N pill: one row of options, each its label with a cell of padding either side and a
2// cell between options. The chosen one sits on a soft raised ground in gold, the others dim and lift
3// to that ground under the pointer. Click an option, or focus the pill and use the arrow keys or
4// space. The choice is posted to the hooks module; a click maps to the option under it by the same
5// widths drawn here. A click on the option already chosen is posted too, so picking the same source
6// again reloads it. `compact` drops the cell between options, for a pane too narrow for the gaps.
7// `outlined` draws each option in its own rounded border, gold on the chosen one, so every option
8// reads as something to press; each then takes 2 more cells and 3 rows.
9
10import type { ClientModule } from 'claude-code'
11
12export type SwitchProps = { options: string[]; value: number; surface?: string; compact?: boolean; outlined?: boolean }
13type S = { v: number; was: number }
14
15const GOLD = '#d4bf7c'
16const RAISE = '#141417'
17const DIM = '#8f9096'
18const FG = '#ffffff'
19const LINE = '#2f3239'
20
21const Switch: ClientModule<SwitchProps, S> = (p, s) => {
22  const { Box, Text } = s.elements
23  const n = Math.max(1, p.options.length)
24  const widths = p.options.map(l => [...l].length + 2 + (p.outlined ? 2 : 0))
25  const gap = p.compact ? 0 : 1
26  // A local choice stands only while the props still hold the value it was made over.
27  const v = s.state && s.state.was === p.value ? s.state.v : p.value
28  const choose = (i: number, again = false) => {
29    const next = Math.min(n - 1, Math.max(0, i))
30    if (next === v && !again) return
31    if (next !== v) s.setState({ v: next, was: p.value })
32    s.post({ pick: next })
33  }
34  // The option under a column: each option owns its cells and the gap after it.
35  const at = (x: number) => {
36    let end = 0
37    for (let i = 0; i < n; i++) {
38      end += (widths[i] ?? 2) + (i < n - 1 ? gap : 0)
39      if (x < end) return i
40    }
41    return n - 1
42  }
43  // Set on every call, so the listeners always read the choice drawn now. The highlight moves on the
44  // press itself, before the hooks module answers: that is the pressed feedback.
45  s.onPointer(ev => {
46    if (ev.type === 'down' && ev.button === 'left') choose(at(ev.x), true)
47  })
48  s.onKey(k => {
49    if (k.key === 'left') choose(v - 1)
50    else if (k.key === 'right') choose(v + 1)
51    else if (k.key === ' ' || k.key === 'return') choose((v + 1) % n)
52  })
53  return Box({
54    flexDirection: 'row',
55    alignItems: 'center',
56    gap,
57    children: p.options.map((label, i) =>
58      Box({
59        key: `o-${i}`,
60        width: widths[i],
61        flexShrink: 0,
62        justifyContent: 'center',
63        ...(p.outlined ? { borderStyle: 'round', borderColor: i === v ? GOLD : LINE } : {}),
64        backgroundColor: i === v ? RAISE : undefined,
65        hover: i === v ? undefined : { backgroundColor: RAISE },
66        children: Text({ color: i === v ? GOLD : DIM, bold: i === v, hover: i === v ? undefined : { color: FG }, children: label }),
67      }),
68    ),
69  })
70}
71
72export default Switch
73
hooks/layout.tsx 113 lines
1// The layout bar: every section of a pane as a tile in its lane, Left or Right. Drag a tile to move
2// it, inside its lane or across to the other; click a tile to hide or show it. With the focus, up
3// and down pick a tile, left and right move it, space hides it. Tiles are boxes a fixed number of
4// cells wide, so a pointer lands on the same tile on every surface, whatever width a font draws.
5// The hint that says so is drawn by the hooks module under the bar, where it can wrap.
6
7import type { ClientModule } from 'claude-code'
8
9export type LayoutTile = { id: string; label: string }
10export type LayoutProps = { main: LayoutTile[]; side: LayoutTile[]; hidden: string[] }
11type S = { drag: string | null; x0: number; seq: string[] | null; was: string; pick: string | null }
12
13// The divider between the lanes in a posted sequence; the hooks module splits on the same mark.
14const CUT = '|'
15const GOLD = '#d4bf7c'
16const RAISE = '#141417'
17const DIM = '#8f9096'
18const INK = '#050506'
19const FG = '#ffffff'
20const LEAD = 5
21const CUT_W = 6
22
23const Layout: ClientModule<LayoutProps, S> = (p, s) => {
24  const { Box, Text } = s.elements
25  const name: Record<string, string> = {}
26  for (const t of [...p.main, ...p.side]) name[t.id] = t.label
27  const base = [...p.main.map(t => t.id), CUT, ...p.side.map(t => t.id)]
28  const key = base.join(',')
29  const st: S = s.state ?? { drag: null, x0: 0, seq: null, was: key, pick: null }
30  // A local order stands only until the props move on from the order it was made over.
31  const seq = st.seq && st.was === key ? st.seq : base
32  const w = (id: string) => (id === CUT ? CUT_W : (name[id]?.length ?? 4) + 2)
33  const lefts = (items: string[]) => {
34    let x = LEAD
35    return items.map(id => {
36      const at = x
37      x += w(id) + 1
38      return at
39    })
40  }
41  const hit = (x: number) => {
42    const xs = lefts(seq)
43    return seq.find((id, i) => id !== CUT && x >= xs[i]! && x < xs[i]! + w(id)) ?? null
44  }
45  // Where a dragged tile lands: before the first other item whose middle is right of the pointer.
46  const place = (id: string, x: number) => {
47    const rest = seq.filter(v => v !== id)
48    const xs = lefts(rest)
49    const i = rest.findIndex((v, k) => x < xs[k]! + w(v) / 2)
50    const at = i < 0 ? rest.length : i
51    return [...rest.slice(0, at), id, ...rest.slice(at)]
52  }
53
54  // Set on every call, so a listener always maps through the order drawn now.
55  s.onPointer(ev => {
56    const cur = s.state ?? st
57    if (ev.type === 'down' && ev.button === 'left') {
58      const id = hit(ev.x)
59      if (id) s.setState({ ...cur, drag: id, x0: ev.x, seq: null, was: key, pick: id })
60    } else if (ev.type === 'move' && cur.drag && Math.abs(ev.x - cur.x0) >= 2) s.setState({ ...cur, seq: place(cur.drag, ev.x), was: key })
61    else if (ev.type === 'up' && cur.drag) {
62      if (cur.seq) s.post({ seq: cur.seq })
63      else s.post({ toggle: cur.drag })
64      s.setState({ ...cur, drag: null })
65    }
66  })
67  s.onKey(k => {
68    const cur = s.state ?? st
69    const tiles = seq.filter(v => v !== CUT)
70    const id = cur.pick && tiles.includes(cur.pick) ? cur.pick : tiles[0]
71    if (!id) return
72    if (k.key === 'up' || k.key === 'down') {
73      const i = tiles.indexOf(id)
74      s.setState({ ...cur, pick: tiles[(i + (k.key === 'down' ? 1 : tiles.length - 1)) % tiles.length]! })
75    } else if (k.key === 'left' || k.key === 'right') {
76      const i = seq.indexOf(id)
77      const j = i + (k.key === 'right' ? 1 : -1)
78      if (j < 0 || j >= seq.length) return
79      const next = [...seq]
80      next[i] = next[j]!
81      next[j] = id
82      s.setState({ ...cur, seq: next, was: key, pick: id })
83      s.post({ seq: next })
84    } else if (k.key === ' ' || k.key === 'return') s.post({ toggle: id })
85  })
86
87  const hidden = new Set(p.hidden)
88  const tile = (id: string) =>
89    id === CUT
90      ? Box({ key: 'cut', width: CUT_W, children: Text({ color: DIM, children: ' Right' }) })
91      : Box({
92          key: `t-${id}`,
93          width: w(id),
94          backgroundColor: st.drag === id ? GOLD : RAISE,
95          children: Text({
96            color: st.drag === id ? INK : hidden.has(id) ? DIM : st.pick === id ? GOLD : FG,
97            strikethrough: hidden.has(id),
98            wrap: 'truncate-end',
99            children: ` ${name[id] ?? id}`,
100          }),
101        })
102  return Box({
103    flexDirection: 'row',
104    gap: 1,
105    children: [
106      Box({ key: 'lead', width: LEAD - 1, children: Text({ color: DIM, children: 'Left' }) }),
107      ...seq.map(tile),
108    ],
109  })
110}
111
112export default Layout
113
hooks/toggle.tsx 50 lines
1// Agon's on/off switch, as a trading app draws an agent's kill switch: a track with a knob, green with
2// the knob at the right when on, grey with it at the left when off, and On or Off beside it so the
3// state never rests on colour alone. A click, or Enter or space while it holds the focus, flips it at
4// once on screen and posts the new state to the hooks module, which owns the bot and redraws from it.
5
6import type { ClientModule } from 'claude-code'
7
8export type ToggleProps = { on: boolean }
9type S = { on: boolean; was: boolean }
10
11const UP = '#3fbf8f'
12const LINE = '#2f3239'
13const FG = '#ffffff'
14const DIM = '#8f9096'
15
16const Toggle: ClientModule<ToggleProps, S> = (p, s) => {
17  const { Box, Text } = s.elements
18  // A local flip stands only while the props still hold the state it was made over.
19  const on = s.state && s.state.was === p.on ? s.state.on : p.on
20  const flip = () => {
21    s.setState({ on: !on, was: p.on })
22    s.post({ on: !on })
23  }
24  s.onPointer(ev => {
25    if (ev.type === 'down' && ev.button === 'left') flip()
26  })
27  s.onKey(k => {
28    if (k.key === 'return' || k.key === ' ') flip()
29  })
30  return Box({
31    flexDirection: 'row',
32    alignItems: 'center',
33    gap: 1,
34    children: [
35      Box({
36        key: 'track',
37        width: 4,
38        flexShrink: 0,
39        flexDirection: 'row',
40        justifyContent: on ? 'flex-end' : 'flex-start',
41        backgroundColor: on ? UP : LINE,
42        children: Text({ color: FG, bold: true, children: '●' }),
43      }),
44      Text({ key: 'word', color: on ? UP : DIM, bold: on, children: on ? 'On' : 'Off' }),
45    ],
46  })
47}
48
49export default Toggle
50
hooks/slider.tsx 92 lines
1// A draggable slider in the Agon theme: drag or click the track, or focus it and use the arrow
2// keys. The value lives here while dragging and is posted to the hooks module on release, so a
3// drag redraws only this region, not the pane.
4
5import type { ClientModule } from 'claude-code'
6
7export type SliderProps = {
8  label: string
9  value: number
10  min: number
11  max: number
12  step: number
13  prefix?: string
14  suffix?: string
15  decimals?: number
16  // The surface drawing it, which a Client module is not told: the track is drawn per surface.
17  surface?: string
18}
19type S = { v: number; drag: boolean; was: number }
20
21const EMBER = '#d4bf7c'
22const TRACK = '#2f3239'
23const TRACK_FILL = '#1d1f24'
24const DIM = '#8f9096'
25const LABEL_W = 9
26const VALUE_W = 9
27
28const Slider: ClientModule<SliderProps, S> = (p, s) => {
29  const { Box, Text } = s.elements
30  const snap = (x: number) => Math.min(p.max, Math.max(p.min, Math.round(x / p.step) * p.step))
31  const trackW = Math.max(8, s.columns - LABEL_W - VALUE_W)
32  const at = (col: number) => snap(p.min + ((col - LABEL_W) / Math.max(1, trackW - 1)) * (p.max - p.min))
33  // A local value stands only while the props still hold the value it was made over: a strategy
34  // switch, or the hooks module taking the posted value, moves the props on.
35  const v = s.state && s.state.was === p.value ? s.state.v : p.value
36
37  // Set on every call, so a drag maps through the range drawn now, not the first one.
38  s.onPointer(ev => {
39    const drag = s.state?.drag === true && s.state.was === p.value
40    if (ev.type === 'down' && ev.button === 'left') s.setState({ v: at(ev.x), drag: true, was: p.value })
41    else if (ev.type === 'move' && drag) s.setState({ v: at(ev.x), drag: true, was: p.value })
42    else if (ev.type === 'up' && drag) {
43      const next = at(ev.x)
44      s.setState({ v: next, drag: false, was: p.value })
45      s.post({ value: next })
46    }
47  })
48  s.onKey(k => {
49    const d = k.key === 'right' || k.key === 'up' ? p.step : k.key === 'left' || k.key === 'down' ? -p.step : 0
50    if (d === 0) return
51    const next = snap(v + (k.shift ? d * 10 : d))
52    s.setState({ v: next, drag: false, was: p.value })
53    s.post({ value: next })
54  })
55
56  const share = Math.min(1, Math.max(0, (v - p.min) / Math.max(p.step, p.max - p.min)))
57  const digits = v.toFixed(p.decimals ?? 0).replace(/^(-?\d+)/, m => m.replace(/\B(?=(\d{3})+$)/g, ','))
58  const knob = Text({ color: EMBER, bold: true, children: '●' })
59  // The terminal draws a cell per glyph, so the track is counted there. A desktop draws ━ wider
60  // than a cell, and a block holding a run of them will not shrink but crushes the value beside
61  // it; there the track is two solid capsules sized by share, gold then dark, a white knob between.
62  let track
63  if (p.surface === 'terminal') {
64    const fill = Math.round(share * (trackW - 1))
65    track = Box({
66      flexDirection: 'row',
67      children: [Text({ color: EMBER, children: '━'.repeat(fill) }), knob, Text({ color: TRACK, children: '━'.repeat(Math.max(0, trackW - fill - 1)) })],
68    })
69  } else
70    track = Box({
71      flexGrow: 1,
72      flexDirection: 'row',
73      alignItems: 'center',
74      children: [
75        Box({ width: `${Math.round(share * 100)}%`, borderStyle: 'round', borderColor: EMBER, backgroundColor: EMBER }),
76        Text({ color: '#ffffff', bold: true, children: '●' }),
77        Box({ flexGrow: 1, borderStyle: 'round', borderColor: TRACK, backgroundColor: TRACK_FILL }),
78      ],
79    })
80  return Box({
81    flexDirection: 'row',
82    alignItems: 'center',
83    children: [
84      Box({ width: LABEL_W, flexShrink: 0, children: Text({ color: DIM, wrap: 'truncate-end', children: p.label }) }),
85      track,
86      Box({ width: VALUE_W, flexShrink: 0, justifyContent: 'flex-end', children: Text({ bold: true, children: `${p.prefix ?? ''}${digits}${p.suffix ?? ''}` }) }),
87    ],
88  })
89}
90
91export default Slider
92
types/index.d.ts 202 lines
1// A token as the band and pane draw it. Only `mint` ever reaches the model, in a draft the person
2// sends; the symbol is outside text and is shown to the person alone.
3export type Tok = {
4  mint: string
5  symbol: string
6  icon: string | null
7  price: number | null
8  change5m: number | null
9  change1h: number | null
10  change6h: number | null
11  change24h: number | null
12  volume24h: number | null
13  mcap: number | null
14  liquidity: number | null
15  holders: number | null
16  organic: number | null
17  mintOff: boolean | null
18  freezeOff: boolean | null
19  decimals: number | null
20}
21
22// The vault line, as list_rules answered it, and when.
23// `text` fits the band's 28 cells; `detail` is the whole of a failure, shown once as a toast.
24export type Vault = { text: string; ok: boolean; at: number; detail?: string }
25
26// How much of the band shows: its name only, the prices line, or the token list too.
27export type BandMode = 'collapsed' | 'line' | 'list'
28
29// The selected token's price history: closes oldest first, or why there is none.
30export type Chart = {
31  key: string
32  closes: number[]
33  volumes: number[]
34  times: number[]
35  error: string | null
36}
37
38// A paper bot: its rules, and what it did on the Agon server's paper ledger at live Jupiter prices.
39// DCA buys a USDC slice every `p1` minutes, `p2` slices in all; Trail buys once, then sells all it
40// holds `p1`% off the peak or `p2`% under its entry, whichever comes first. No order is larger than
41// `cap` USDC, and the buys together never pass `budget`.
42export type BotStrategy = 'dca' | 'trail'
43export type BotDraft = {
44  strategy: BotStrategy
45  mint: string
46  symbol: string
47  budget: number
48  p1: number
49  p2: number
50  mode: 'copilot' | 'auto'
51  cap: number
52  slip?: number
53}
54// One paper fill as paper_trade recorded it: `usdc` in USDC base units (6 decimals) and `units` in
55// the token's base units, digit strings; `price` is USDC per whole token at that fill.
56export type BotFill = { at: number; side: 'buy' | 'sell'; usdc: string; units: string; price: number; slot: number | null; stamp: string; why: string }
57// A refusal or an error, kept with its reasons and the time the bot asks again.
58export type BotEvent = { at: number; kind: 'refused' | 'error'; text: string; reasons: PaperReason[]; stamp: string | null }
59// The bot's books, exact, as digit strings (realized may be negative): USDC spent on buys and got
60// from sells, the token units held, their cost at average cost, and realized P&L, in base units.
61export type BotBook = { spent: string; received: string; held: string; cost: string; realized: string; buys: number; sells: number }
62export type Bot = BotDraft & {
63  id: string
64  status: 'running' | 'paused' | 'done'
65  created: number
66  decimals: number
67  book: BotBook
68  fills: BotFill[]
69  events: BotEvent[]
70  nextAt: number
71  retryAt: number
72  strikes: number
73  peak: number | null
74  price: number | null
75  priceAt: number | null
76  why: string | null
77}
78
79// The selected token's order book as Jupiter quotes measure it: at each size, the price buying
80// (ask) and selling (bid) would get, the token amount, and the impact against Jupiter's price.
81export type DepthLevel = { side: 'ask' | 'bid'; usd: number; price: number | null; amount: number | null; impact: number | null }
82export type Depth = { mint: string; at: number; mid: number; quote: string; levels: DepthLevel[]; error: string | null; src?: Src | null }
83
84// Where market data came from: the Agon server's cached routes ('agon', `host` the upstream it read)
85// or straight from Jupiter or GeckoTerminal while the server is down ('direct'). `at` is when the
86// upstream answered (ms), `stale` the server's reason when it served its last good value, `why` why
87// the server was not used.
88export type Src = { via: 'agon' | 'direct'; host: string; at: number; stale: string | null; why: string | null }
89// One mint's live price, as the last price poll read it.
90export type Live = { price: number; change24h: number | null; liquidity: number | null; decimals: number | null }
91// The last price poll: its prices, where they came from, and why the newest poll failed, if it did.
92export type PriceFeed = { prices: Record<string, Live>; src: Src | null; error: string | null }
93
94// The saved wallet's holdings as Jupiter's holdings endpoint lists them (amounts in whole tokens),
95// with Jupiter's prices for them; `wallet` is null when none is saved.
96export type Holding = { mint: string; amount: number }
97export type Portfolio = { wallet: string | null; at: number; sol: number; holdings: Holding[]; tokens: Tok[]; skipped: number; error: string | null }
98
99// Where a pane's sections sit: the main lane and the side lane, side by side once the pane is wide
100// and the side lane on top when it is narrow, the hidden ones, and the density. Kept across sessions.
101export type PaneLayout = { main: string[]; side: string[]; hidden: string[]; dense: boolean }
102
103// A paper order as the Trade tab builds it: a buy spending `usdc` USDC, or a sell of `pct` percent of
104// the paper holding for USDC. `symbol` is shown to the person alone and never sent.
105// A sell names its amount either as a percent of the holding or as exact base units (`base`), worked out
106// from a USDC value at the live price.
107export type PaperOrder = { side: 'buy' | 'sell'; mint: string; symbol: string; usdc: number | null; pct: number | null; base?: string | null }
108// One reason from the guard's verdict, as the server's paper_trade returns it.
109export type PaperReason = { rule: string; message: string; observed?: number; limit?: number; unit?: string }
110// What the last paper order came to: filled, refused by check_trade (with its reasons), or not placed.
111// `stamp` is the verdict's data slot and rule version, null when no verdict came back.
112export type PaperLast = { ok: boolean; text: string; reasons: PaperReason[]; stamp: string | null }
113// The server's paper ledger, as paper_portfolio returns it. Amounts are base units as digit strings;
114// USDC values are decimal strings, `valueUsdc` null when no quote priced that holding.
115export type PaperFill = { at: string; side: 'buy' | 'sell'; inputMint: string; outputMint: string; inAmount: string; outAmount: string; quoteSlot: number | null }
116export type PaperHolding = { mint: string; amount: string; valueUsdc: string | null; why?: string }
117export type PaperBook = {
118  account: string
119  startedWithUsdc: string
120  valueUsdc: string
121  pnlUsdc: string
122  pnlPct: number
123  unpriced: number
124  priceSource: string
125  pricedAt: string
126  holdings: PaperHolding[]
127  fills: PaperFill[]
128  fillCount: number
129  note: string
130}
131// Paper trading on the Agon server, as the plugin's adapter reads it: 'on' once the server lists its
132// paper trade tool. `reason` says why it is off; `note` is the server's own paper note.
133export type Paper = {
134  status: 'unknown' | 'off' | 'on'
135  at: number
136  reason: string | null
137  last: PaperLast | null
138  portfolio: PaperBook | null
139  portfolioError: string | null
140  note: string | null
141}
142
143declare module 'claude-code' {
144  interface PluginState {
145    agon: {
146      tokens: Tok[]
147      watch: Tok[]
148      listError: string | null
149      vault: Vault | null
150      selected: string | null
151      mode: BandMode
152      logos: Record<string, string>
153      pixels: Record<string, string>
154      paneNote: string | null
155      cardMenu: string | null
156      sidebarUp: boolean
157      source: number
158      interval: number
159      sort: number
160      query: string
161      range: number
162      chart: Chart | null
163      charts: Record<string, { closes: number[]; times: number[]; at: number }>
164      desc: boolean
165      audited: boolean
166      view: 'list' | 'table' | 'cards'
167      paneView: 'start' | 'discover' | 'watch' | 'trade' | 'bots' | 'portfolio'
168      sortOpen: boolean
169      bots: Bot[]
170      botDraft: BotDraft | null
171      botSel: string | null
172      layouts: Record<string, PaneLayout>
173      layoutEdit: string | null
174      watchlist: string[]
175      tradeAmt: number
176      tradePct: number
177      bandPage: number
178      bandRows: number
179      bandView: 'prices' | 'bots'
180      search: { query: string; tokens: Tok[]; error: string | null; done: boolean } | null
181      picked: Tok | null
182      portfolio: Portfolio | null
183      portMode: 'paper' | 'mainnet'
184      portSpan: number
185      portKind: 'value' | 'pnl' | 'pie'
186      fillsOpen: boolean
187      paperSide: 'buy' | 'sell'
188      paperAmt: string
189      paperPreset: { pct: number; text: string; side: 'buy' | 'sell' } | null
190      infoOpen: string | null
191      depth: Depth | null
192      priceFeed: PriceFeed | null
193      paper: Paper | null
194      paperPending: PaperOrder | null
195      paperUsdc: number
196      recent: string[]
197      startNote: { text: string; ok: boolean } | null
198      startDraft: string
199    }
200  }
201}
202