Skip to content
maxh213Public

About

Terminal dashboard that shows how much allowance you have left on your credits / subs for LLMs. Also it can serve as a router for these into marestail

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

628 Commits

Folders and files

Repository files navigation

Dandelion Dashboard

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.

Providers

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) - runs claude -p "/usage" (90s timeout) and shows the session, weekly and per-model weekly windows with a reset countdown. In the live dashboard it also shows status: <description> under the header while status.claude.com reports an incident (see DANDELION_CLAUDE_STATUS_URL).
  • claude-work (claude · work) - runs the same command with CLAUDE_CONFIG_DIR set to the work config dir and shows the same windows for the work account, with the same live status: line. Without that dir it is unavailable and never runs claude.
  • claude-deepseek (claude · deepseek) - reads ANTHROPIC_AUTH_TOKEN from <config dir>/settings.json and GETs https://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 as monthly · $20.00 of $20.00. A null limit says openrouter key has no spending limit. Without the dir or a token the panel says no deepseek config — CLAUDE_CONFIG_DIR=~/.claude-deepseek claude. The token and the response body are never printed. The live dashboard shows the same status: 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-session cookie, with or without the __Secure-session= prefix) and GETs https://ollama.com/settings with it (10s timeout) for the plan's session and weekly usage. The session window is rolling, so it trips at 90%, and the weekly window joins headroom and evaporation; each takes its reset from the page. The API key in settings.json cannot read these numbers, which is why the probe needs the browser session. Without the config dir the panel says no ollama config — CLAUDE_CONFIG_DIR=~/.claude-ollama claude, without the cookie file no ollama.com session — save __Secure-session in ollama-session, and when ollama.com answers with its sign-in page ollama.com session expired — replace ollama-session. The cookie and the page are never printed.
  • agy - runs agy -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.toml for base_url and the credential key, read from the [providers."managed:kimi-code"] table (the key also from its .oauth sub-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, then credentials/) 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 is usage used/limit and the rolling 5h window is the 300-minute limits detail, so a used_ratio stuck at 0 does not hide real usage. With an expired token and a refresh_token, kimi refreshes it itself. Dandelion starts kimi 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/usage only 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 says kimi token expired and kimi web did not refresh it — run kimi once. Without a refresh_token, or with kimi not installed, it says kimi token expired — run kimi once. With no credential file it starts kimi web --no-open --port <port>, waits up to 20s for the token it prints, and reads kimi 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 rolling 5h window, each with a reset countdown.
  • grok - GETs https://cli-chat-proxy.grok.com/v1/billing?format=credits with the bearer token from <grok home>/auth.json (10s timeout; the token is never printed). A 401 runs grok models once (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.jsonl without 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 - runs codex 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 starts codex app-server and reads account/rateLimits/read over 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 shows status: <description> under the header while status.openai.com reports an incident (see DANDELION_OPENAI_STATUS_URL).
  • codex-animus (codex · animus) - runs the same codex login status and codex app-server probe with CODEX_HOME set to the animus home (DANDELION_CODEX_ANIMUS_HOME, default ~/.codex-animus) and shows that account's windows, with the same live status: line. When the home is not a directory the panel says no animus codex home — CODEX_HOME=~/.codex-animus codex login and never runs codex.
  • cursor - reads the access token from the cursor-agent auth file without running cursor-agent, and POSTs to the dashboard API's GetCurrentPeriodUsage and GetPlanInfo (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 shows status: <description> under the header while status.cursor.com reports an incident (see DANDELION_CURSOR_STATUS_URL).
  • junie - reads the newest completion snapshot from <junie home>/sessions/<id>/events.jsonl without running junie, visiting sessions from <junie home>/sessions/index.jsonl newest 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 run hermes portal login; when the token is expired it says to run hermes once.
  • kilo (api balance) - runs kilo 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.

Run Commands

  • 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 every DANDELION_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 as claude · 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 shows probing…. At start, every provider whose entry in snapshot.json has 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 of probing…; until the first round settles that data counts as not settled for everything else (route boxes still probing, c/C and l/L flash nothing to copy / nothing to launch, no notification baseline, no history sample, no snapshot write, s sorts it as probing, x, space and R act as on a pending panel), each panel switches to live data when its own probe settles, and a missing or unreadable snapshot.json or a DANDELION_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 by last 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 as dandelion route would, and a provider that has never settled ok shows its failure as before. Two boxes at the top show the answers dandelion route and dandelion route --high would print; they update when a round settles or routing is toggled. The frame fits the terminal: the route and route --high boxes stay at the top, and the provider list scrolls with ↑↓/jk so earlier panels, personal claude included, 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 (--once is always 72). Keys: ↑↓/jk select a panel, space routing on/off for the selected provider, h hide the selected panel, H show hidden panels, g a full-screen usage-over-time graph of the selected provider (first panel if none is selected; esc, q or g goes back), listed in the ? help as g usage graph of the selected panel · esc/q/g back · v compact · w why above the keys line, r refresh everything, R or enter re-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), c copy the route line and C the route --high line to the clipboard (wl-copy, else xclip -selection clipboard, else an OSC 52 sequence written to the terminal) with a 2s copied: <line> flash in place of the summary line (nothing to copy while probing, when the answer is none or routes.json is bad; copy failed when every method fails), x run the selected panel's suggested fix command (hermes portal login, hermes once, cursor-agent login, claude with CLAUDE_CONFIG_DIR set to the work config dir or to the deepseek config dir, codex login with CODEX_HOME set to the animus home, kimi when its token has expired, grok or junie when 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, x on a panel without one flashes no fix for this panel, and nothing happens without a selection (the hint and key exist only in the live dashboard; --once, route and --json are unchanged and no token is ever passed on the command line), l launch the line shown in the route box and L the line in the route --high box: the command is built exactly as dandelion run builds it (claude-work, claude-deepseek and claude-ollama set CLAUDE_CONFIG_DIR, codex-animus sets CODEX_HOME, model and effort passed through, no extra arguments) and runs in the foreground like x (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 launch is flashed while probing, when the answer is none or routes.json is bad, and <command>: command not found when the CLI is not on PATH; the ? help lists it as l/L launch, s cycle the panel order (dashboard, headroom, reset), t reset times, v toggle a compact view, q quit (or Ctrl-C), ? help. t switches every reset countdown and the summary line between relative (↻ 4d3h, the default) and absolute local time (↻ Sat 21:40, or ↻ Oct 12 21:40 when the reset is 6 or more days away); the mode is saved in view.json (see below) and --once always shows countdowns. s cycles 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: headroom or · sort: reset while a non-dashboard order is active, and the order is saved in view.json (see below). Route boxes, route and the eligibility state file do not depend on it. The ? help lists it as s sort on its first line. w shows under each route box the lines dandelion route --why and dandelion route --high --why would 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, w again hides them, and the toggle is for the session only (not saved in view.json). v switches 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 ones routing 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 what q does: 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 and DANDELION_KIMI_PORT is freed. If an l, L or x command 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, including q, so a chunk such as ll starts only one command. Dandelion then exits with 128 plus the signal number (143 for SIGTERM, 129 for SIGHUP; q exits 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, route and --json install 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's snapshot.json without 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-age
  • dandelion - Run the live dashboard from anywhere after npm link; dandelion --once runs it once and exits
  • dandelion route (or npm start -- route) - Run every probe once and print one line naming the subscription to use right now, such as claude-opus-5-5 max claude, then exit; it prints none and exits 1 when no provider can be routed, and exits 2 when routes.json is bad. It never draws the dashboard, even on a terminal.
  • dandelion route --high (or npm start -- route --high) - Run every probe once and print one line naming the strongest model that still has quota, such as claude-fable-5-1 max claude-work, then exit; it prints none and exits 1 when no chain entry is available, and exits 2 when routes.json is bad. It never draws the dashboard, even on a terminal.
  • dandelion --json (or npm 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 --json works anywhere in the arguments unless the first argument is route, run, help, --help or -h (route, help, --help and -h ignore it, and run passes it on to the launched CLI). The object has generatedAt, providers (one entry per provider in dashboard order with id, displayName, status, planLabel, eligible, windows of label, kind, usedPct, resetsAt and projectedFullAt (the ISO time the window is on pace to hit 100% before it resets, the same projection as → 100% in …), balance, snapshotAt, note, reason and fetchedAt, leaving out whatever a provider does not have), route and routeHigh, the exact lines dandelion route and dandelion route --high print, or none. With a bad routes.json, route and routeHigh are null, routesError holds <path>: <problem>, the usual dandelion: 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.5 or a missing value means no cache) answers from the live dashboard's snapshot.json instead of probing, with the same rules as route --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 (generatedAt is now, each fetchedAt is the entry's own time, and route, routeHigh and 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 or snapshot.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 (or npm 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 line dandelion route prints, or none: claude 63% · kimi 12% · grok 0% → claude-opus-5-5 max claude. Providers that are unavailable, failed or without windows (kilo, codex with 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 for route. With a bad routes.json the route part is routes file error, the usual dandelion: 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's snapshot.json under the same rules as --json --max-age; otherwise it probes. --json wins when both are given, and route --line is plain route. 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 (or npm 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: text is the route part of --line, the exact line dandelion route prints, or none (routes file error with a bad routes.json, with the usual dandelion: routes file ... line on stderr); percentage is the highest rounded used percent over the windows of ok providers (0 when there are none); class is calm (below 50), warm (50-79), hot (80-94) or critical (95 and up) for that percentage; tooltip has 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's snapshot.json under the same rules as --line --max-age; otherwise it probes. --json and --line win 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 as route / route --high does (same probes, eligibility state file, routes.json and 90% trip), then launch the matching CLI with stdio inherited, as listed under Account token below. --high before 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 as route --max-age does (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 as 0 or 1.5 that is not a positive integer means no cache and is dropped too); every other argument after run, and everything after a literal --, is appended to the launched command, such as dandelion run -- -p "hello". It prints none on stderr and exits 1 without launching when nothing can be routed, prints the dandelion: routes file … line on stderr and exits 2 when routes.json is bad, prints dandelion: <command>: command not found on stderr and exits 127 when the CLI is not on PATH, 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 for run. route and route --high are unchanged.
  • dandelion --help (also -h and help) - 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 as dandelion rout, prints dandelion: unknown command rout and the usage to stderr and exits 2 without probing; --json anywhere still wins. Likewise, when the first argument is not route, run, help, --help or -h and no --json, --line or --waybar is present, any argument starting with - other than --once and --max-age (and the value right after --max-age), such as dandelion --jsno, prints dandelion: unknown option --jsno and the usage to stderr and exits 2 without probing; route and run arguments are handled as before.
  • npm test - Run unit tests
  • npm run qa - Run E2E tests

The app runs once when stdout or stdin is not a terminal, as if --once were given.

Usage history

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

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.

  1. 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.
  2. 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.

routes.json

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.

Env-var Ledger

  • DANDELION_KILO_REFERENCE - The reference amount (in dollars) used to calculate the gauge fill percentage for the kilo probe. Defaults to 20. 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 as 20abc reads as the number (20).
  • DANDELION_CLAUDE_DEEPSEEK_CONFIG_DIR - The deepseek claude config dir. Defaults to ~/.claude-deepseek when unset or empty. The probe reads settings.json there for the OpenRouter token and dandelion run passes the dir to claude as CLAUDE_CONFIG_DIR. When it is not a directory, or the token is missing, the panel says no 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-ollama when unset or empty. dandelion run passes it to claude as CLAUDE_CONFIG_DIR, so its settings.json should point ANTHROPIC_BASE_URL at https://ollama.com with an Ollama API key as ANTHROPIC_AUTH_TOKEN; the probe reads only the ollama-session cookie file there. When it is not a directory the panel says no ollama config — CLAUDE_CONFIG_DIR=~/.claude-ollama claude. The cookie is never printed.
  • DANDELION_CODEX_ANIMUS_HOME - The Codex home of the codex-animus account, passed to codex as CODEX_HOME by the probe and by dandelion run. Defaults to ~/.codex-animus when unset or empty. When it is not a directory the panel says no animus codex home — CODEX_HOME=~/.codex-animus codex login.
  • DANDELION_CLAUDE_WORK_CONFIG_DIR - The work claude config dir the claude-work probe passes to claude as CLAUDE_CONFIG_DIR. Defaults to ~/.claude-work when unset or empty. When it is not a directory the panel says to log in with CLAUDE_CONFIG_DIR=~/.claude-work claude.
  • DANDELION_KIMI_PORT - The local port kimi web is started on for the kimi probe when no credential file is present, or when the access token has expired and kimi must refresh it. Defaults to 59177. Any value other than an integer from 1 to 65535 makes that fallback unavailable.
  • DANDELION_KIMI_HOME - The kimi home the kimi probe reads config.toml and credentials/ from. Defaults to ~/.kimi-code when unset or empty. The token is never printed or written.
  • DANDELION_GROK_HOME - The grok home directory the grok probe reads auth.json and logs/unified.jsonl from. Defaults to ~/.grok when unset or empty. The app never writes to it. The token is never printed.
  • DANDELION_GROK_BILLING_URL - The credits URL the grok probe GETs. Defaults to https://cli-chat-proxy.grok.com/v1/billing?format=credits when unset or empty.
  • DANDELION_CURSOR_AUTH_FILE - The cursor-agent auth file the cursor probe reads accessToken from. Defaults to ~/.config/cursor/auth.json when unset or empty. Without a token the panel says to run cursor-agent login.
  • DANDELION_CURSOR_API_BASE - The base URL of the cursor dashboard API the cursor probe POSTs to. Defaults to https://api2.cursor.sh when unset or empty.
  • DANDELION_JUNIE_HOME - The junie home directory the junie probe reads sessions/index.jsonl and each session's events.jsonl from. Defaults to ~/.junie when unset or empty. The app never writes to it.
  • DANDELION_JUNIE_REFERENCE - The number of credits a full junie balance holds, used for the junie credits window. Defaults to 1000000. 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 the hermes probe reads Nous Portal tokens from. Defaults to ~/.hermes/auth.json when unset or empty. Without a token the panel says to run hermes portal login. The tokens are never printed or written.
  • DANDELION_HERMES_PORTAL_BASE - The base URL of the Nous Portal the hermes probe GETs /api/oauth/account from. Defaults to https://portal.nousresearch.com when 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 to 300; any value that is not a positive integer, or is above 2147483 (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 and DANDELION_REFRESH_SECONDS instead of the normal wait. Weekly windows, ineligible providers and windows already at 100% do not count, and the catch-up after suspend still uses DANDELION_REFRESH_SECONDS. Validated like DANDELION_REFRESH_SECONDS: a value that is not a positive integer, or is above 2147483, counts as unset. Unset by default.
  • DANDELION_CLAUDE_STATUS_URL - The Anthropic status JSON the live dashboard GETs once per full round (not on R; 10s timeout, no auth headers) for the claude, claude-work and claude-deepseek panels. Defaults to https://status.claude.com/api/v2/status.json when unset or empty. When status.indicator is anything but none, each of those panels shows status: <status.description> directly under its header, in the warm colour for minor and the hot colour for any other indicator (plain text under NO_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, --json and notifications never fetch it.
  • DANDELION_OPENAI_STATUS_URL - The OpenAI status JSON the live dashboard GETs once per full round (not on R; 10s timeout, no auth headers) for the codex and codex-animus panels, skipped for whichever of them is listed in DANDELION_DISABLE. Defaults to https://status.openai.com/api/v2/status.json when unset or empty. It follows the same rules as DANDELION_CLAUDE_STATUS_URL: status: <status.description> directly under the panel header, warm for minor, hot for any other indicator except none, plain under NO_COLOR, nothing on any failure. Display only: routing, the route boxes, route, route --high, run, --once, --json, --line, --waybar and notifications never fetch it.
  • DANDELION_CURSOR_STATUS_URL - The Cursor status JSON the live dashboard GETs once per full round (not on R; 10s timeout, no auth headers) for the cursor panel, skipped when that provider is listed in DANDELION_DISABLE. Defaults to https://status.cursor.com/api/v2/status.json when unset or empty. It follows the same rules as DANDELION_CLAUDE_STATUS_URL: status: <status.description> directly under the panel header, warm for minor, hot for any other indicator except none, plain under NO_COLOR, nothing on any failure. Display only: routing, the route boxes, route, route --high, run, --once, --json, --line, --waybar and notifications never fetch it.
  • DANDELION_STATE_FILE - The route eligibility state file the live dashboard writes when space toggles a provider, and route and --once read. 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 with h, and view.json, the s order and t mode, live in the same directory and is written the same way. snapshot.json, the settled results the live dashboard saves for route --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 to history.json next to the eligibility state file. --once and route never write it.
  • DANDELION_ROUTES_FILE - The routes file route, route --high, --once and the live dashboard read their lines from. Defaults to routes.json next to package.json in the checkout src/main.ts really 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 through notify-send after 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 missing notify-send is ignored. Notifications that come due while an x, l or L command runs in the foreground are held back and sent once, after it exits. --once and route never notify. Off by default.
  • DANDELION_NOTIFY_THRESHOLDS - Comma-separated whole percentages from 1 to 100, such as 50,90, that replace 80 and 95 as the usage levels DANDELION_NOTIFY announces. Whitespace is ignored and invalid entries are dropped; unset, empty or all-invalid keeps 80,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. Unlike h, 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 --json providers, --line and snapshot.json, and routing treats it as unavailable, with the routing rules, the --high chain and the 90% trip unchanged. route --max-age, run --max-age, --json --max-age and --line --max-age count a snapshot as complete when it has a fresh entry for every enabled provider. An unknown id prints dandelion: 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.

About

Terminal dashboard that shows how much allowance you have left on your credits / subs for LLMs. Also it can serve as a router for these into marestail

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages