Dandelion is a terminal dashboard that shows how much of your AI allowances you have left: subscription usage windows for claude, agy, kimi, grok, codex and cursor, the credits balance for junie, the Nous Portal credits for hermes, and the API balance for kilo.
Called dandelion because dandelion seeds can lay dormant for a period of time before bursting into life. It's why they're such a prolific weed. But anyway this fits the naming convention I started with marestail and also it's sort of similar to you'll use up a session window for an llm then it will come back lol.
Pace warning: when a window of known length (rolling 5h windows, and windows whose label contains week, which are 7 days) is on course to hit 100% before it resets, a dim line → 100% in ~1h20m (before reset) appears directly under its row, in the live dashboard and in --once. The projection assumes the usage so far was spread evenly over the time elapsed in the window, and stays silent for windows of unknown length, a missing or past reset, 0% or full usage, and windows under 5% elapsed. Routing and route output are unaffected.
Usage sparkline: in the live dashboard's full view, a window row with at least 2 history.json samples (see Usage history) for the same provider, window position and label in the last 24 hours shows a dim 8-cell sparkline between its percent and its reset countdown. Each cell is one 3-hour bucket of those 24 hours, oldest on the left, showing the newest sample in the bucket from ▁ (0%) to █ (100%) in eight steps; a bucket without samples is a space. Under NO_COLOR the cells are ASCII, _ . - = # from 0% to 100%. The sparkline takes 9 cells from the gauge and is the first thing dropped when the terminal is too narrow for it (the gauge would fall under 6 cells), before any label or countdown is cut. --once, the compact view, --json, --line, --waybar and the g graph are unchanged.
Pace marker: on those same windows of known length with a reset in the future and no further away than the window length, the gauge shows a dim │ (| under NO_COLOR) at the point where steady usage would be, that is the share of the window already elapsed, in the live dashboard and in --once. The marker replaces the cell at that position and the gauge keeps its width, so a fill past the marker means you are ahead of pace. Windows of unknown length, a missing or past reset, a reset further away than the window length, and balance rows get no marker.
Panels always appear in this order. A provider whose CLI is missing, fails, times out or prints unexpected output renders as a dim panel with the reason; a probe that crashes unexpectedly renders as a failed panel with the fixed reason probe crashed (the error message is never shown) and routes as failed; when claude, agy or kilo exits with an error, the reason is Command exited with an error: and the first line the CLI wrote to stderr (stripped of control characters, with long secrets replaced by …).
claude(claude · personal) - runsclaude -p "/usage"(90s timeout) and shows the session, weekly and per-model weekly windows with a reset countdown. In the live dashboard it also showsstatus: <description>under the header while status.claude.com reports an incident (seeDANDELION_CLAUDE_STATUS_URL).claude-work(claude · work) - runs the same command withCLAUDE_CONFIG_DIRset to the work config dir and shows the same windows for the work account, with the same livestatus:line. Without that dir it is unavailable and never runs claude.claude-deepseek(claude · deepseek) - readsANTHROPIC_AUTH_TOKENfrom<config dir>/settings.jsonand GETshttps://openrouter.ai/api/v1/key(10s timeout) for the key's spending limit and what remains of it. The window counts as weekly, so it joins headroom and never trips at 90%. The plan label is the reset period and the dollars left, such asmonthly · $20.00 of $20.00. A null limit saysopenrouter key has no spending limit. Without the dir or a token the panel saysno deepseek config — CLAUDE_CONFIG_DIR=~/.claude-deepseek claude. The token and the response body are never printed. The live dashboard shows the samestatus:line under its header.claude-ollama(claude · ollama) - Claude Code on Ollama Cloud. Reads the ollama.com login cookie from<config dir>/ollama-session(the value of the__Secure-sessioncookie, with or without the__Secure-session=prefix) and GETshttps://ollama.com/settingswith it (10s timeout) for the plan's session and weekly usage. Thesessionwindow is rolling, so it trips at 90%, and theweeklywindow joins headroom and evaporation; each takes its reset from the page. The API key insettings.jsoncannot read these numbers, which is why the probe needs the browser session. Without the config dir the panel saysno ollama config — CLAUDE_CONFIG_DIR=~/.claude-ollama claude, without the cookie fileno ollama.com session — save __Secure-session in ollama-session, and when ollama.com answers with its sign-in pageollama.com session expired — replace ollama-session. The cookie and the page are never printed.agy- runsagy -p "/usage"(60s timeout) and shows each model group's windows; agy reports remaining percent, shown as used percent.kimi(kimi code) - reads the OAuth access token from~/.kimi-code(config.tomlforbase_urland the credentialkey, read from the[providers."managed:kimi-code"]table (the key also from its.oauthsub-table, where kimi writes it), or from the top-level keys before the first table when that table is absent, never from another provider's table, thencredentials/) and GETs{base}/usages(10s request timeout). The token is never printed or written. With a valid token, on the legacy plan the weekly window isusageused/limit and the rolling5hwindow is the 300-minutelimitsdetail, so aused_ratiostuck at 0 does not hide real usage. With an expired token and arefresh_token, kimi refreshes it itself. Dandelion startskimi web --no-open --port <port>, polls the credential file every 500ms for up to 20s (an unreadable file mid-write counts as not fresh yet), stops the server, then calls the coding API as above. kimi web's own/api/v1/oauth/usageonly exposes the zeroed ratios, so it is not used for this. Dandelion never writes kimi's credential file and never calls the refresh endpoint, because refresh tokens may rotate and would break kimi's own login. If the refresh fails (kimi exited, the port was taken or 20s passed) it sayskimi token expired and kimi web did not refresh it — run kimi once. Without arefresh_token, or withkiminot installed, it sayskimi token expired — run kimi once. With no credential file it startskimi web --no-open --port <port>, waits up to 20s for the token it prints, and readskimi web's local usage endpoint/api/v1/oauth/usage(10s request timeout), then shuts the server down with SIGTERM, then SIGKILL after 5s. It shows the weekly window and the rolling5hwindow, each with a reset countdown.grok- GETshttps://cli-chat-proxy.grok.com/v1/billing?format=creditswith the bearer token from<grok home>/auth.json(10s timeout; the token is never printed). A 401 runsgrok modelsonce (20s) to refresh the login and retries the request once. A response whose current period carries no usage percent counts as 0% used, because the API leaves the field out when nothing has been used. When that request fails, or the response has no period and no usage percent, it reads the newest billing snapshot from<grok home>/logs/unified.jsonlwithout running grok, and shows the credits window with a reset countdown, the subscription tier and the snapshot's age. A snapshot older than 48h is shown dim as stale. When the snapshot's billing period has already ended, grok has reset its credits, so the window shows 0% with no reset countdown and the caption ends with· period ended since snapshot; routing then sees grok as unused. Without a snapshot the panel says to run grok once.codex- runscodex login status(15s timeout). Logged in with an API key, it shows "api-key billing · no usage windows", because API usage has no windows. Logged in with ChatGPT, it startscodex app-serverand readsaccount/rateLimits/readover JSON-RPC on stdio (30s timeout), then shows the primary and secondary windows with a reset countdown. The server is then shut down with SIGTERM, then SIGKILL after 5s. In the live dashboard it also showsstatus: <description>under the header while status.openai.com reports an incident (seeDANDELION_OPENAI_STATUS_URL).codex-animus(codex · animus) - runs the samecodex login statusandcodex app-serverprobe withCODEX_HOMEset to the animus home (DANDELION_CODEX_ANIMUS_HOME, default~/.codex-animus) and shows that account's windows, with the same livestatus:line. When the home is not a directory the panel saysno animus codex home — CODEX_HOME=~/.codex-animus codex loginand never runs codex.cursor- reads the access token from the cursor-agent auth file without running cursor-agent, and POSTs to the dashboard API'sGetCurrentPeriodUsageandGetPlanInfo(15s timeout each) to show the total, auto and api windows with a reset countdown and the plan name and price. The token is never printed or written. In the live dashboard it also showsstatus: <description>under the header while status.cursor.com reports an incident (seeDANDELION_CURSOR_STATUS_URL).junie- reads the newest completion snapshot from<junie home>/sessions/<id>/events.jsonlwithout running junie, visiting sessions from<junie home>/sessions/index.jsonlnewest first, and shows the credits used against a reference, the balance and the snapshot's age. A snapshot older than 48h is shown dim as stale. Without a snapshot the panel says to run junie once.hermes- reads the Nous Portal tokens from the hermes auth file without running hermes, GETs/api/oauth/account(15s timeout) for the credits window with a reset countdown and remaining versus the monthly grant, and never prints the tokens. Without a token the panel says to runhermes portal login; when the token is expired it says to runhermes once.kilo(api balance) - runskilo profile(20s timeout) and shows the balance against a reference as a row laid out like a window row:balance $12.31, a gauge filled with the remaining share, the remaining percent, and the gauge coloured by the spent share on the usual scale.
All thirteen probes run in parallel. Window gauges are coloured by usage: below 50% calm, 50-79% warm, 80-94% hot, 95% and above critical.
npm start- Run the live dashboard: panels fill in as each probe settles, a fleet summary line shows how many windows are at 80% or more and the next reset, and everything is re-probed everyDANDELION_REFRESH_SECONDS, or at once when the dashboard notices its data is older than that (for example after the machine wakes from suspend). Each settled panel's caption ends with its data age, such asclaude · personal · 3m ago, and while a panel is being re-probed it keeps showing its previous numbers with a spinner after the provider name; only a panel that has never settled showsprobing…. At start, every provider whose entry insnapshot.jsonhas status ok and was fetched at most 24 hours ago is drawn at once as if it were being re-probed (its stored rows, a spinner and a caption ending in the age of that entry, such as· 7m ago) instead ofprobing…; until the first round settles that data counts as not settled for everything else (route boxes still probing,c/Candl/Lflashnothing to copy/nothing to launch, no notification baseline, no history sample, no snapshot write,ssorts it as probing,x,spaceandRact as on a pending panel), each panel switches to live data when its own probe settles, and a missing or unreadablesnapshot.jsonor aDANDELION_DISABLEd provider starts as before. When a re-probe of a provider that last settled ok comes back unavailable or failed, its panel keeps showing the last good rows (or balance line) dimmed and uncoloured, followed bylast probe failed: <reason>, and its caption ends with the age of that good data (· 7m ago) until the provider settles ok again; the route boxes,c/C, notifications and the history still treat it as failed, exactly asdandelion routewould, and a provider that has never settled ok shows its failure as before. Two boxes at the top show the answersdandelion routeanddandelion route --highwould print; they update when a round settles or routing is toggled. The frame fits the terminal: therouteandroute --highboxes stay at the top, and the provider list scrolls with↑↓/jkso earlier panels, personalclaudeincluded, stay reachable. The list shows every panel from the first when they all fit, and otherwise scrolls only as far as needed to keep the whole selected panel in view (from its header when it is taller than the list). The frame also fits the terminal's width: the banner, dividers and the two route boxes span the full width, the gauges grow (up to 80 cells) so the percent and reset columns sit near the right edge, and a narrower terminal shrinks the gauges and labels, cutting lines only as a last resort (never mid-escape, always closing the style); a resize redraws at the new width, and without a usable width the layout is the fixed 72 columns (--onceis always 72). Keys:↑↓/jk selecta panel,space routing on/offfor the selected provider,h hidethe selected panel,H show hiddenpanels,ga full-screen usage-over-time graph of the selected provider (first panel if none is selected;esc,qorggoes back), listed in the?help asg usage graph of the selected panel · esc/q/g back · v compact · w whyabove the keys line,rrefresh everything,Rorenterre-probe only the selected panel's provider (nothing happens without a selection, while a round or that panel's own probe is running; the refresh timer is not reset; a provider is also re-probed on its own, without a key press, once a reset time it reported is at least 60s in the past, using the same single-panel re-probe (spinner, previous rows kept, route boxes recomputed when it settles), at most once per provider per passed reset value for the session, and never while a round or that panel's own probe is running, so the panel and the routes do not keep a window that has already reset at its old percent),ccopy therouteline andCtheroute --highline to the clipboard (wl-copy, elsexclip -selection clipboard, else an OSC 52 sequence written to the terminal) with a 2scopied: <line>flash in place of the summary line (nothing to copywhile probing, when the answer isnoneor routes.json is bad;copy failedwhen every method fails),xrun the selected panel's suggested fix command (hermes portal login,hermes once,cursor-agent login,claudewithCLAUDE_CONFIG_DIRset to the work config dir or to the deepseek config dir,codex loginwithCODEX_HOMEset to the animus home,kimiwhen its token has expired,grokorjuniewhen there is no snapshot or it is stale) in the foreground: dandelion leaves the alternate screen and raw mode, runs it with inherited stdio, Ctrl-C inside the fix command ends only that command and returns to the dashboard (dandelion ignores SIGINT while it runs), no scheduled refresh, automatic re-probe or notification starts while it runs (notifications that came due meanwhile are sent after it exits, not dropped), and when it exits restores the screen, redraws and starts a full refresh round unless one is running; panels with a fix end their caption with· x fix,xon a panel without one flashesno fix for this panel, and nothing happens without a selection (the hint and key exist only in the live dashboard;--once,routeand--jsonare unchanged and no token is ever passed on the command line),llaunch the line shown in theroutebox andLthe line in theroute --highbox: the command is built exactly asdandelion runbuilds it (claude-work,claude-deepseekandclaude-ollamasetCLAUDE_CONFIG_DIR,codex-animussetsCODEX_HOME, model and effort passed through, no extra arguments) and runs in the foreground likex(alternate screen and raw mode left, SIGINT ignored by dandelion while it runs, no refresh, re-probe or notification meanwhile, and notifications held back meanwhile are sent after it exits; on exit the screen is restored and a full refresh round starts),nothing to launchis flashed while probing, when the answer isnoneor routes.json is bad, and<command>: command not foundwhen the CLI is not on PATH; the?help lists it asl/L launch,scycle the panel order (dashboard,headroom,reset),treset times,vtoggle a compact view,qquit (or Ctrl-C),?help.tswitches every reset countdown and the summary line between relative (↻ 4d3h, the default) and absolute local time (↻ Sat 21:40, or↻ Oct 12 21:40when the reset is 6 or more days away); the mode is saved inview.json(see below) and--oncealways shows countdowns.scycles the order of the panels: the fixed dashboard order, then most headroom first (100 minus the highest used percent over a panel's windows), then soonest future reset first; panels without windows or a future reset, and panels still probing, come last in dashboard order, and ties keep dashboard order. The order is recomputed as results settle, the selection stays on the same provider, the summary line ends with· sort: headroomor· sort: resetwhile a non-dashboard order is active, and the order is saved inview.json(see below). Route boxes,routeand the eligibility state file do not depend on it. The?help lists it ass sorton its first line.wshows under each route box the linesdandelion route --whyanddandelion route --high --whywould print after the route line, for the dashboard's current results, eligibility and clock (recomputed whenever the boxes are; nothing is shown while a round is probing or when routes.json is bad), stacked (route lines, then route --high lines) and cut to the frame width; the provider list gets fewer rows so the frame still fits,wagain hides them, and the toggle is for the session only (not saved inview.json).vswitches the provider list to one line per provider: the name with the▸selection marker, a 20-cell gauge and percent for the window with the highest usage (rolling and weekly windows first, else any window), and↻the soonest reset among its windows, coloured like the full view. Providers without windows show their note, balance or reason dim, pending ones show the spinner, ineligible onesrouting off, and the selected row is bold; rows are cut to the frame width. The banner, summary line and route boxes stay the same, every other key works as before, and the view is not saved.- Signals: while the live dashboard runs, SIGTERM and SIGHUP (
kill <pid>, a closed tmux pane, a session manager) do whatqdoes: they leave the alternate screen, turn raw mode off and stop the probe child processes (kimi web,codex app-server,claude -p; SIGTERM, then SIGKILL after 5s), so the terminal is restored andDANDELION_KIMI_PORTis freed. If anl,Lorxcommand is running in the foreground at that moment, the signal is forwarded to it first, dandelion waits for it to exit (SIGKILL after 5s) and only then exits; while it runs every key is ignored, includingq, so a chunk such asllstarts only one command. Dandelion then exits with 128 plus the signal number (143 for SIGTERM, 129 for SIGHUP;qexits 0), a further SIGTERM or SIGHUP while it shuts down is absorbed (no second shutdown, the exit code stays that of the first signal), and the signal handlers are removed when the dashboard ends.--once,routeand--jsoninstall no such handlers. npm start -- --once- Run the dashboard once and exit.--once --max-age <seconds>(also when stdout or stdin is not a terminal) prints it from the live dashboard'ssnapshot.jsonwithout probing when the snapshot is complete and at most that old, under the same rules as--json --max-age; otherwise it probes as plain--once. It never writes any state file, and the live dashboard ignores--max-agedandelion- Run the live dashboard from anywhere afternpm link;dandelion --onceruns it once and exitsdandelion route(ornpm start -- route) - Run every probe once and print one line naming the subscription to use right now, such asclaude-opus-5-5 max claude, then exit; it printsnoneand exits 1 when no provider can be routed, and exits 2 whenroutes.jsonis bad. It never draws the dashboard, even on a terminal.dandelion route --high(ornpm start -- route --high) - Run every probe once and print one line naming the strongest model that still has quota, such asclaude-fable-5-1 max claude-work, then exit; it printsnoneand exits 1 when no chain entry is available, and exits 2 whenroutes.jsonis bad. It never draws the dashboard, even on a terminal.dandelion --json(ornpm start -- --json) - Run every probe once and print one JSON object on one line, then exit 0; it never draws the dashboard, even on a terminal, and--jsonworks anywhere in the arguments unless the first argument isroute,run,help,--helpor-h(route,help,--helpand-hignore it, andrunpasses it on to the launched CLI). The object hasgeneratedAt,providers(one entry per provider in dashboard order withid,displayName,status,planLabel,eligible,windowsoflabel,kind,usedPct,resetsAtandprojectedFullAt(the ISO time the window is on pace to hit 100% before it resets, the same projection as→ 100% in …),balance,snapshotAt,note,reasonandfetchedAt, leaving out whatever a provider does not have),routeandrouteHigh, the exact linesdandelion routeanddandelion route --highprint, ornone. With a badroutes.json,routeandrouteHigharenull,routesErrorholds<path>: <problem>, the usualdandelion: routes file ...line goes to stderr and the exit code is still 0.--max-age <seconds>anywhere in the arguments (N a positive integer as a separate argument;0,-5,1.5or a missing value means no cache) answers from the live dashboard'ssnapshot.jsoninstead of probing, with the same rules asroute --max-age: when the file parses, holds an entry for every provider and every entry was fetched at most N seconds ago, no probe runs and the object is built from those results (generatedAtis now, eachfetchedAtis the entry's own time, androute,routeHighand the ineligible providers come from the current routes.json, state file and clock); otherwise it probes exactly as plain--json. It reads the state file and never writes it orsnapshot.json, and never prints tokens. A Waybar custom module that reuses the live dashboard's data when it is at most 10 minutes old:"custom/dandelion": { "exec": "dandelion --json --max-age 600 | jq -c '{text: .route, tooltip: ([.providers[] | .id + \" \" + .status] | join(\"\\n\"))}'", "return-type": "json", "interval": 300 }.dandelion --line(ornpm start -- --line) - Run every probe once and print one line of plain text for a tmux status bar or shell prompt, then exit 0; it never draws the dashboard and prints no ANSI escape sequence, even on a terminal. The line has one segment per provider that is ok and has at least one usage window, in dashboard order, as<id> <worst used percent>%(the highest used percent over its windows, rounded), joined by·, then→and the exact linedandelion routeprints, ornone:claude 63% · kimi 12% · grok 0% → claude-opus-5-5 max claude. Providers that are unavailable, failed or without windows (kilo,codexwith an API key) are left out; routing off in the state file does not remove a segment but changes the route part exactly as it does forroute. With a badroutes.jsonthe route part isroutes file error, the usualdandelion: routes file ...line goes to stderr and the exit code is still 0.--max-age <seconds>anywhere in the arguments answers from the live dashboard'ssnapshot.jsonunder the same rules as--json --max-age; otherwise it probes.--jsonwins when both are given, androute --lineis plainroute. It never writes the state, snapshot, view, hidden or history files, installs no signal handlers and never prints tokens. In tmux:set -g status-right '#(dandelion --line --max-age 600)'.dandelion --waybar(ornpm start -- --waybar) - Run every probe once and print one JSON object on one line for a Waybar custom module with"return-type": "json", then exit 0; it never draws the dashboard, prints no ANSI escape sequence and never prints tokens. Fields:textis the route part of--line, the exact linedandelion routeprints, ornone(routes file errorwith a badroutes.json, with the usualdandelion: routes file ...line on stderr);percentageis the highest rounded used percent over the windows of ok providers (0 when there are none);classiscalm(below 50),warm(50-79),hot(80-94) orcritical(95 and up) for that percentage;tooltiphas one line per ok provider with windows, in dashboard order, as<id> <worst used percent>%, joined with\n.--max-age <seconds>anywhere in the arguments answers from the live dashboard'ssnapshot.jsonunder the same rules as--line --max-age; otherwise it probes.--jsonand--linewin when combined with it. Sample Waybar module:"custom/dandelion": { "exec": "dandelion --waybar --max-age 600", "return-type": "json", "interval": 300 }.dandelion run/dandelion run --high- Resolve the route exactly asroute/route --highdoes (same probes, eligibility state file,routes.jsonand 90% trip), then launch the matching CLI with stdio inherited, as listed under Account token below.--highbefore a literal--picks the quality chain and is not passed on;--max-age <seconds>before a literal--resolves the route from the live dashboard's last snapshot exactly asroute --max-agedoes (see Route --max-age), so a fresh snapshot launches at once without waiting on any probe, and it and its value are not passed on (a numeric value such as0or1.5that is not a positive integer means no cache and is dropped too); every other argument afterrun, and everything after a literal--, is appended to the launched command, such asdandelion run -- -p "hello". It printsnoneon stderr and exits 1 without launching when nothing can be routed, prints thedandelion: routes file …line on stderr and exits 2 whenroutes.jsonis bad, printsdandelion: <command>: command not foundon stderr and exits 127 when the CLI is not onPATH, and otherwise exits with the child's exit code. While the child runs, dandelion ignores Ctrl-C (SIGINT) so the child, which receives it from the terminal itself, decides how to end and its exit code (such as 130) is still the one dandelion exits with, and forwards SIGTERM and SIGHUP to the child, exiting with its status (such as 143 or 129) only after it has exited; the handlers are removed when the child exits and exist only forrun.routeandroute --highare unchanged.dandelion --help(also-handhelp) - Print a short usage text to stdout and exit 0 without running any probe. A first argument that is not a known command and does not start with-, such asdandelion rout, printsdandelion: unknown command routand the usage to stderr and exits 2 without probing;--jsonanywhere still wins. Likewise, when the first argument is notroute,run,help,--helpor-hand no--json,--lineor--waybaris present, any argument starting with-other than--onceand--max-age(and the value right after--max-age), such asdandelion --jsno, printsdandelion: unknown option --jsnoand the usage to stderr and exits 2 without probing;routeandrunarguments are handled as before.npm test- Run unit testsnpm run qa- Run E2E tests
The app runs once when stdout or stdin is not a terminal, as if --once were given.
Every settled round, and every single-panel re-probe, of the live dashboard also replaces snapshot.json in the same directory with the current settled results: a JSON array with one entry per provider holding its status, windows and fetchedAt (never tokens or auth file contents). It is what route --max-age reads (see Route --max-age); it is replaced atomically, a failed write never breaks the dashboard, and --once, --json and route never write it.
Every settled round of the live dashboard appends one sample per usage window of each ok provider (provider, window label, used percent, reset time, timestamp) to history.json in the dandelion state dir, next to the eligibility state file ($XDG_STATE_HOME/dandelion/, or the directory of DANDELION_STATE_FILE; DANDELION_HISTORY_FILE names the file directly). Each sample carries the window's position in its provider's list, so windows that share a label are charted separately. Samples older than 30 days are dropped; samples older than a day are thinned to one per window per hour, and at most the newest 40000 are kept, so the full 30 days stay reachable at the default refresh rate. The graph heads each chart with its latest value, marks a drop in usage (a reset) with ↓ (v under NO_COLOR), says how long the history spans, and says why it is empty for a provider that is probing, unavailable or has no usage windows; on a terminal too short for every window it ends with … N more windows (enlarge the terminal). Each write first re-reads the file and merges in samples another dashboard wrote meanwhile (exact duplicates of the same provider, window position, label and timestamp are dropped), so two dashboards do not erase each other's history. The file is replaced atomically; a missing, corrupt or unwritable file never breaks the dashboard, it just means less history. --once, route and route --high never write it.
Press g on a panel to see one chart per window: bars of used percent over time from the first sample to now, a ↓ (v with NO_COLOR) under each reset, and the time axis in local time. NO_COLOR also switches the bars to #. esc, q or g returns to the dashboard; q on the dashboard quits.
route must be the first argument; later arguments are ignored, except that --high anywhere after it switches to the quality chain below and --why anywhere after it adds an explanation (see Route --why), and --max-age <seconds> answers from the live dashboard's last snapshot instead of probing (see Route --max-age). It routes among claude, claude-work, claude-deepseek, claude-ollama, agy, kimi, grok, codex, codex-animus, cursor, junie and hermes, in dashboard order, taking each one that is ok and has at least one usage window. kilo is never routed, because it reports a balance rather than windows, and a codex account logged in with an API key has no windows, so it is not routed either.
Each window is rolling (claude session, claude-ollama session, kimi 5h, codex 5h, agy 5h (Claude+GPT · 5h, Gemini · 5h)), weekly (any other label containing "week", grok credits, cursor total, auto and api, junie credits, hermes credits, claude-deepseek spending limit) or other, which route ignores. The plain route leaves out the cursor api window, because its cursor line is auto, which draws on the auto pool; --high still gates cursor on all its windows. A window's left is 100 minus its used percent, compared without rounding. Probes keep fractional used percents (kimi counters, cursor, codex, grok, hermes, claude-deepseek, claude-ollama), so a kimi 5h counter of 179/200 is 89.5% and does not trip; only text is rounded half up (window rows, the compact view, notifications, --why and graph titles), --json prints whole-number usedPct, and snapshot.json and history.json store the unrounded value.
- Evaporation: a weekly window evaporates when it resets after now and before the next local midnight with less than 97% and more than 0% left. If any provider has one, route prints the max line of the provider whose evaporating window has the most left.
- Most headroom: otherwise each provider's binding is the lowest left over its rolling and weekly windows (100 when it has neither), and route prints the standard line of the provider with the highest binding.
Ties go to the provider earlier in dashboard order.
The trip: before both rules, route skips every tripped account. An account is tripped when any of its rolling windows (claude, claude-work and claude-ollama session, kimi 5h, codex and codex-animus 5h, agy 5h (Claude+GPT · 5h, Gemini · 5h)) is 90% used or more; 90% itself trips, and weekly and other windows never trip. A tripped account's evaporating weekly window is ignored by rule 1 and its binding is ignored by rule 2. It is the same 90% trip route --high uses. A panel of an ok, eligible account that is tripped shows tripped on the right of its header in the live full view and in --once, where routing off appears for ineligible panels (which show that and not tripped), followed by · back <local time> with exactly the time route --why prints for that account (left out when a tripping window has no reset or it has passed); it is coloured like a hot window (plain text under NO_COLOR), the compact view is unchanged, and routing and every other output are unaffected. When no account is left once ineligible, unavailable and tripped accounts are skipped, route prints none and exits 1.
Route --max-age: route --max-age N and route --high --max-age N (N a positive integer of seconds, as a separate argument; anything else, such as 0, -5, 1.5 or a missing value, means no cache) read snapshot.json from the dandelion state dir. When it parses, holds an entry for every provider and every entry was fetched at most N seconds ago, they print exactly what route / route --high would print for those results, with the same exit codes, without running any probe; the ineligible providers come from the current state file, and routes.json and the clock are the current ones. --why combines with it and explains the same results. A missing, unreadable, corrupt, incomplete or too old file never fails the command: it probes exactly as without the flag. dandelion --json --max-age N and dandelion --once --max-age N read it the same way (see --json and npm start -- --once). Plain route, route --high, run and plain --json never read the file; run --max-age N reads it the same way, and after a literal -- --max-age is an ordinary argument for the launched CLI.
Eligibility: ineligible providers are dropped before both rules, so an ineligible provider is never routed, not even by evaporation. In the live dashboard, select a panel with ↑↓/jk and press space to toggle its routing on or off; an ineligible panel shows routing off and keeps showing its usage. Non-routable panels (no usage windows, unavailable or failed) cannot be toggled and flash not routable (no usage windows) instead. The choice is kept in the state file (DANDELION_STATE_FILE), a JSON object where false marks a provider ineligible; a missing, unreadable or corrupt file makes every provider eligible. The live dashboard also picks up changes another dashboard or a hand edit makes to the state file: it re-reads it at the start of every full refresh round, after every single-panel re-probe settles, and right before c, C, l and L act (never on a plain redraw), so the route boxes, the routing off label and the copied or launched line match what dandelion route would answer. --once and route read the state file and never write it.
View state: the live dashboard keeps the s order and the t reset-time mode in view.json next to the state file, as {"sort":"headroom","absoluteResets":true} (sort is dashboard, headroom or reset). It reads the file at start and rewrites it atomically whenever s or t changes the mode; a missing, unreadable or corrupt file, an unknown sort or a non-boolean absoluteResets falls back to dashboard order and relative countdowns, and a failed save flashes view state not saved and keeps the new mode for the session. --once, route, route --high, --json and run never read or write it.
Hiding panels: in the live dashboard h hides the selected panel and selects the next visible one (the previous one if it was last). Hiding is display only: a hidden provider is still probed and routed exactly as before, and its eligibility is untouched. While panels are hidden a dim <n> hidden · H to show line follows the last panel; H shows them in place with hidden right-aligned in the header, h on one unhides it, and H again hides them. ↑↓/jk skip hidden panels while they are not shown. The hidden ids are kept as a JSON array in hidden.json next to the state file; a missing, unreadable or corrupt file hides nothing, and a failed save flashes hidden state not saved and changes nothing. --once, route and route --high never read or write it.
The standard and max lines of each provider are in routes.json (see below):
| provider | standard line | max line |
|---|---|---|
| claude | route.claude.standard |
route.claude.max |
| claude-work | route.claude-work.standard |
route.claude-work.max |
| claude-deepseek | route.claude-deepseek.standard |
route.claude-deepseek.max |
| claude-ollama | route.claude-ollama.standard |
route.claude-ollama.max |
| agy | route.agy.standard |
route.agy.max |
| kimi | route.kimi.standard |
route.kimi.max |
| grok | route.grok.standard |
route.grok.max |
| codex | route.codex.standard |
route.codex.max |
| codex-animus | route.codex-animus.standard |
route.codex-animus.max |
| cursor | route.cursor.standard |
route.cursor.max |
| junie | route.junie.standard |
route.junie.max |
| hermes | route.hermes.standard |
route.hermes.max |
Account token: both route and route --high print <line> <provider id>, such as the route.claude-work.standard line followed by claude-work, because several providers can share a line and the account decides how to launch it; none stays alone. claude launches claude as usual, claude-work means launching claude with CLAUDE_CONFIG_DIR set to DANDELION_CLAUDE_WORK_CONFIG_DIR (default ~/.claude-work), claude-deepseek means launching claude with CLAUDE_CONFIG_DIR set to DANDELION_CLAUDE_DEEPSEEK_CONFIG_DIR (default ~/.claude-deepseek), claude-ollama means launching claude with CLAUDE_CONFIG_DIR set to DANDELION_CLAUDE_OLLAMA_CONFIG_DIR (default ~/.claude-ollama), codex-animus means launching codex with CODEX_HOME set to DANDELION_CODEX_ANIMUS_HOME (default ~/.codex-animus), and every other id launches its own CLI. dandelion run does this launch: claude, claude-work, claude-deepseek and claude-ollama run claude --model <model> --effort <effort> (the --effort pair only when the line has an effort), codex and codex-animus run codex --model <model> -c model_reasoning_effort=<effort> (the -c pair only when the line has an effort), and agy, kimi, grok, cursor (as cursor-agent), junie and hermes run their own CLI with --model <model>; their efforts are not passed. The table lives in src/app/launch.ts.
Route --high: quality first, with no evaporation rule and no headroom comparison. It walks this chain from rank 1 down and prints the line of the first available entry, taken from routes.json:
| rank | providers | gating windows | line |
|---|---|---|---|
| 1 | claude, claude-work | fable: the Fable weekly window plus session |
high.fable |
| 2 | codex, codex-animus | (all) | high.codex |
| 3 | cursor | (all) | high.cursor |
| 4 | claude, claude-work | (all) | high.opus |
| 5 | grok | (all) | high.grok |
| 6 | agy | (all) | high.agy |
An entry with a matcher is gated on the windows whose label contains the matcher in any case, plus every rolling window; a matched window the account does not report counts as 0% used. An (all) entry is gated on every window the provider reports, except the windows another entry of the same provider matches, so the rank 4 entry ignores the Fable window. The 90% trip: an entry is available on an account only when the account is eligible, ok with at least one window, and every gating window is under 90% used; at 90% it pops down to the next entry. Ineligible, unavailable and failed providers are skipped, and kimi, junie, hermes, kilo, claude-deepseek and claude-ollama are never in the chain, so --high does not use junie. --high does not use hermes. --high does not use claude-deepseek. --high does not use claude-ollama. When both claude accounts are available at one rank, the one with more left (100 minus its highest gating used percent) wins, and a tie goes to claude; the two codex accounts at rank 2 work the same way, and a tie goes to codex. When no entry is available it prints none and exits 1.
Route --why: dandelion route --why and dandelion route --high --why print the same route line first, exactly as without --why, then at most two explanation lines, and exit with the same code (1 for none, 2 with a bad routes.json, which still prints nothing on stdout). For route, the first line names the rule and its numbers, such as evaporation: kimi weekly 41% left resets 21:40 before midnight (reset time in local time) or headroom: claude binding 63% left (claude-work 40%, agy 22%), and the second lists the skipped accounts by reason, such as tripped: claude-work (session 92%, back Sat 21:40); ineligible: grok; unavailable: junie, hermes. A tripped entry ends with , back <local time> (the t reset format: weekday and time, or month, day and time when 6 or more days away) when its reset is known and in the future; with several windows at 90% or more it is the latest of their resets, and it is left out when a tripping window has no reset time or the reset has passed. For route --high, the first line names the winning rank, entry and account, such as rank 4 opus on claude: gating 71% used, and the second says why each higher rank was skipped, such as skipped: rank 1 fable tripped at 95%; rank 2 codex no open account; rank 3 cursor no open account. When nothing is routable the route line is none and the explanation still lists what was skipped. Plain route and route --high print exactly what they always did.
To change a routed model, edit routes.json. The lines route and route --high print are data in routes.json at the repo root; the rules, the tie order, the --high chain order, its providers, the fable matcher and the 90% trip stay in code, so the order of keys in the file means nothing. dandelion finds the file from the real path of src/main.ts after following symlinks, never from the working directory, so dandelion route through an npm link symlink reads the checkout's file from any folder. DANDELION_ROUTES_FILE points at a different file.
{
"route": {
"claude": { "standard": "<line>", "max": "<line>" },
"claude-work": { "standard": "<line>", "max": "<line>" },
"claude-deepseek": { "standard": "<line>", "max": "<line>" },
"claude-ollama": { "standard": "<line>", "max": "<line>" },
"agy": { "standard": "<line>", "max": "<line>" },
"kimi": { "standard": "<line>", "max": "<line>" },
"grok": { "standard": "<line>", "max": "<line>" },
"codex": { "standard": "<line>", "max": "<line>" },
"codex-animus": { "standard": "<line>", "max": "<line>" },
"cursor": { "standard": "<line>", "max": "<line>" },
"junie": { "standard": "<line>", "max": "<line>" },
"hermes": { "standard": "<line>", "max": "<line>" }
},
"high": { "fable": "<line>", "codex": "<line>", "cursor": "<line>", "opus": "<line>", "grok": "<line>", "agy": "<line>" }
}route holds one entry per routed provider: standard for the headroom rule and max for evaporation. high holds one line per --high rank, named fable, codex, cursor, opus, grok and agy for ranks 1 to 6. A line is <model> or <model> <effort>: one or two words, one space apart. dandelion prints lines as written and never checks model names.
route, route --high and --once read and check the whole file once per run; the live dashboard reads it once at start. A bad file is an error, never a fallback: when the file is missing, unreadable or not valid JSON, or when an entry is missing, a key is unknown (kilo included), a line is not a non-empty string, or a line has the wrong shape, route and route --high print nothing on stdout, print dandelion: routes file <path>: <what is wrong> on stderr and exit 2, even when nothing could be routed. Exit 1 stays reserved for none. --once prints its dashboard as usual, the same line on stderr, and exits 0. The live dashboard keeps every panel, and both route boxes show routes file error over the start of what is wrong.
DANDELION_KILO_REFERENCE- The reference amount (in dollars) used to calculate the gauge fill percentage for thekiloprobe. Defaults to20. If set to an empty string, no reference gauge is shown. If set to a custom number, the gauge will fill relative to that amount. A value that does not start with a number, zero or a negative number also shows no gauge, and a number followed by other text such as20abcreads as the number (20).DANDELION_CLAUDE_DEEPSEEK_CONFIG_DIR- The deepseek claude config dir. Defaults to~/.claude-deepseekwhen unset or empty. The probe readssettings.jsonthere for the OpenRouter token anddandelion runpasses the dir to claude asCLAUDE_CONFIG_DIR. When it is not a directory, or the token is missing, the panel saysno deepseek config — CLAUDE_CONFIG_DIR=~/.claude-deepseek claude. The token is never printed.DANDELION_CLAUDE_OLLAMA_CONFIG_DIR- The Ollama claude config dir. Defaults to~/.claude-ollamawhen unset or empty.dandelion runpasses it to claude asCLAUDE_CONFIG_DIR, so itssettings.jsonshould pointANTHROPIC_BASE_URLathttps://ollama.comwith an Ollama API key asANTHROPIC_AUTH_TOKEN; the probe reads only theollama-sessioncookie file there. When it is not a directory the panel saysno ollama config — CLAUDE_CONFIG_DIR=~/.claude-ollama claude. The cookie is never printed.DANDELION_CODEX_ANIMUS_HOME- The Codex home of thecodex-animusaccount, passed to codex asCODEX_HOMEby the probe and bydandelion run. Defaults to~/.codex-animuswhen unset or empty. When it is not a directory the panel saysno animus codex home — CODEX_HOME=~/.codex-animus codex login.DANDELION_CLAUDE_WORK_CONFIG_DIR- The work claude config dir theclaude-workprobe passes to claude asCLAUDE_CONFIG_DIR. Defaults to~/.claude-workwhen unset or empty. When it is not a directory the panel says to log in withCLAUDE_CONFIG_DIR=~/.claude-work claude.DANDELION_KIMI_PORT- The local portkimi webis started on for thekimiprobe when no credential file is present, or when the access token has expired and kimi must refresh it. Defaults to59177. Any value other than an integer from 1 to 65535 makes that fallback unavailable.DANDELION_KIMI_HOME- The kimi home thekimiprobe readsconfig.tomlandcredentials/from. Defaults to~/.kimi-codewhen unset or empty. The token is never printed or written.DANDELION_GROK_HOME- The grok home directory thegrokprobe readsauth.jsonandlogs/unified.jsonlfrom. Defaults to~/.grokwhen unset or empty. The app never writes to it. The token is never printed.DANDELION_GROK_BILLING_URL- The credits URL thegrokprobe GETs. Defaults tohttps://cli-chat-proxy.grok.com/v1/billing?format=creditswhen unset or empty.DANDELION_CURSOR_AUTH_FILE- The cursor-agent auth file thecursorprobe readsaccessTokenfrom. Defaults to~/.config/cursor/auth.jsonwhen unset or empty. Without a token the panel says to runcursor-agent login.DANDELION_CURSOR_API_BASE- The base URL of the cursor dashboard API thecursorprobe POSTs to. Defaults tohttps://api2.cursor.shwhen unset or empty.DANDELION_JUNIE_HOME- The junie home directory thejunieprobe readssessions/index.jsonland each session'sevents.jsonlfrom. Defaults to~/.juniewhen unset or empty. The app never writes to it.DANDELION_JUNIE_REFERENCE- The number of credits a full junie balance holds, used for thejuniecredits window. Defaults to1000000. If set to an empty string, there is no reference: the panel shows the balance without a window and junie is not routed. Any other value that is not a positive number uses the default.DANDELION_HERMES_AUTH_FILE- The hermes auth file thehermesprobe reads Nous Portal tokens from. Defaults to~/.hermes/auth.jsonwhen unset or empty. Without a token the panel says to runhermes portal login. The tokens are never printed or written.DANDELION_HERMES_PORTAL_BASE- The base URL of the Nous Portal thehermesprobe GETs/api/oauth/accountfrom. Defaults tohttps://portal.nousresearch.comwhen unset or empty.DANDELION_REFRESH_SECONDS- Seconds the live dashboard waits after a round of probes settles before it probes again; after suspend the dashboard refreshes at once when the last round ended longer ago than this. Defaults to300; any value that is not a positive integer, or is above2147483(the longest delay a timer can hold), uses the default.DANDELION_HOT_REFRESH_SECONDS- Opt-in faster refresh for the live dashboard: when set and a round has settled with an ok, eligible provider whose rolling window is at 80% or more and under 100% used, the next round starts after the smaller of this andDANDELION_REFRESH_SECONDSinstead of the normal wait. Weekly windows, ineligible providers and windows already at 100% do not count, and the catch-up after suspend still usesDANDELION_REFRESH_SECONDS. Validated likeDANDELION_REFRESH_SECONDS: a value that is not a positive integer, or is above2147483, counts as unset. Unset by default.DANDELION_CLAUDE_STATUS_URL- The Anthropic status JSON the live dashboard GETs once per full round (not onR; 10s timeout, no auth headers) for theclaude,claude-workandclaude-deepseekpanels. Defaults tohttps://status.claude.com/api/v2/status.jsonwhen unset or empty. Whenstatus.indicatoris anything butnone, each of those panels showsstatus: <status.description>directly under its header, in the warm colour forminorand the hot colour for any other indicator (plain text underNO_COLOR). A failed fetch, a timeout, a response status outside 2xx or unparseable JSON shows nothing. Display only: routing, the route boxes,route,route --high,run,--once,--jsonand notifications never fetch it.DANDELION_OPENAI_STATUS_URL- The OpenAI status JSON the live dashboard GETs once per full round (not onR; 10s timeout, no auth headers) for thecodexandcodex-animuspanels, skipped for whichever of them is listed inDANDELION_DISABLE. Defaults tohttps://status.openai.com/api/v2/status.jsonwhen unset or empty. It follows the same rules asDANDELION_CLAUDE_STATUS_URL:status: <status.description>directly under the panel header, warm forminor, hot for any other indicator exceptnone, plain underNO_COLOR, nothing on any failure. Display only: routing, the route boxes,route,route --high,run,--once,--json,--line,--waybarand notifications never fetch it.DANDELION_CURSOR_STATUS_URL- The Cursor status JSON the live dashboard GETs once per full round (not onR; 10s timeout, no auth headers) for thecursorpanel, skipped when that provider is listed inDANDELION_DISABLE. Defaults tohttps://status.cursor.com/api/v2/status.jsonwhen unset or empty. It follows the same rules asDANDELION_CLAUDE_STATUS_URL:status: <status.description>directly under the panel header, warm forminor, hot for any other indicator exceptnone, plain underNO_COLOR, nothing on any failure. Display only: routing, the route boxes,route,route --high,run,--once,--json,--line,--waybarand notifications never fetch it.DANDELION_STATE_FILE- The route eligibility state file the live dashboard writes when space toggles a provider, androuteand--onceread. Defaults to$XDG_STATE_HOME/dandelion/eligibility.json, else~/.local/state/dandelion/eligibility.json, when unset or empty. Writes go to a temp file in the same directory, then rename over it.hidden.json, the panels the live dashboard hides withh, andview.json, thesorder andtmode, live in the same directory and is written the same way.snapshot.json, the settled results the live dashboard saves forroute --max-age, lives there too.DANDELION_HISTORY_FILE- The usage history file the live dashboard appends a sample per window to after each settled round. Defaults tohistory.jsonnext to the eligibility state file.--onceandroutenever write it.DANDELION_ROUTES_FILE- The routes fileroute,route --high,--onceand the live dashboard read their lines from. Defaults toroutes.jsonnext topackage.jsonin the checkoutsrc/main.tsreally lives in, when unset or empty. A relative path is taken from the working directory.DANDELION_NOTIFY- When set to a non-empty value, the live dashboard sends a desktop notification throughnotify-sendafter a probe settles: when a window crosses 80% or 95% used, when an account that had a rolling window at 90% or more has none left (a recovered account), naming each window that dropped below 90%, and when a weekly window starts to evaporate (resets before local midnight with less than 97% and more than 0% left), with the time left, and when a weekly window resets and its usage drops (claude weekly reset: 3% used). The first round only sets a baseline, and the same event for the same provider, window and reset period fires once per session. A missingnotify-sendis ignored. Notifications that come due while anx,lorLcommand runs in the foreground are held back and sent once, after it exits.--onceandroutenever notify. Off by default.DANDELION_NOTIFY_THRESHOLDS- Comma-separated whole percentages from 1 to 100, such as50,90, that replace 80 and 95 as the usage levelsDANDELION_NOTIFYannounces. Whitespace is ignored and invalid entries are dropped; unset, empty or all-invalid keeps80,95. When one probe crosses several thresholds, only the highest is announced, and each threshold fires once per window and reset period. Other notifications and the fleet summary are unaffected.DANDELION_DISABLE- A comma-separated list of provider ids (claude,claude-work,claude-deepseek,claude-ollama,agy,kimi,grok,codex,codex-animus,cursor,junie,hermes,kilo) to switch off entirely; whitespace around the ids is ignored. Unlikeh, which only hides a panel that is still probed and routed, a disabled provider is never probed: no CLI is started, no file is read and no request is made. It has no panel in the live dashboard or--once, is absent from--jsonproviders,--lineandsnapshot.json, and routing treats it as unavailable, with the routing rules, the--highchain and the 90% trip unchanged.route --max-age,run --max-age,--json --max-ageand--line --max-agecount a snapshot as complete when it has a fresh entry for every enabled provider. An unknown id printsdandelion: DANDELION_DISABLE: unknown provider <id>once on stderr and is otherwise ignored. Unset or empty disables nothing.NO_COLOR- If set, disables ANSI colors and uses ASCII fallback rendering.