Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

luna-doctor

Alpha. Until 1.0.0, the CLI, the JSON schema and measured gateway behaviour can change without a major bump. If you embed this tool in automation, pin to an exact tag.

LunaRoute runs your coding agents on open models. LunaRoute measures capacity in concurrent requests, not tokens. This tool explains what your LunaRoute agent traffic does to that concurrency, and what to change.

LunaRoute bills concurrent sessions, not tokens. There is no quota or token-budget dimension to it. The account admits a fixed number of concurrent requests at once. Every symptom below is that number being exceeded.

When the account is full, the gateway waits. It can also demote the request to a background tier, and then hold the stream open with keep-alives while it produces nothing. Part of that same overflow also arrives directly as a 429 (rate_limit_error, CONCURRENT_REQUEST_LIMIT_EXCEEDED). A 429 in your logs is therefore a concurrency symptom, not a quota that you can wait out. Often the harness kills a stall with an error string of its own making instead. That is why a grep of the logs for 429 finds only part of the picture.

luna-doctor reads the session logs that the machine already has. It reconstructs what was asked for at what tier, and what came back. Then it tells you what to adjust. Thresholds are measured on your machine, not assumed from ours.

Install

Requirements: Python 3.9+. Everything below, discover, analyze, fix, chart and the bare default run, is offline and needs no credentials. capacity reads a disk cache the same way. The only fetches in the tool are opt-in. They use the routing key that lunaroute login already stored: capacity --online, and analyze --online / fix --online for the model catalog.

pipx install luna-doctor   # installs the `luna-doctor` command
luna-doctor --version      # prints the installed version

Every release is an alpha. pipx installs it, because the index holds no final release.

To run from a checkout instead:

git clone https://github.com/jesserobbins/luna-doctor.git
cd luna-doctor
pipx install .

Give the install to your agent instead: docs/install-with-an-agent.md holds one block to paste, which installs the tool, reads the account's capacity and applies the settings that fix names.

Or without packaging, put it on your PATH:

ln -s "$PWD/bin/luna-doctor" ~/.local/bin/luna-doctor
luna-doctor --version

Quick start

luna-doctor                  # the default run: the last hour analyzed, plus the chart
luna-doctor discover          # what log stores exist, how big, how fresh
luna-doctor analyze           # last 24h: what happened and what to adjust
luna-doctor analyze --now     # last 15 minutes (alias for --since 15m)
luna-doctor analyze --since 7d --json   # machine-readable, for cron/CI
luna-doctor analyze --plan <plan name or number>   # declare the account's width
luna-doctor analyze --baseline prev     # …this window against the one before it
luna-doctor analyze --style plain       # undecorated text, for a paste or a pipe
luna-doctor explain           # one request: what it waited for, and whose room it was
luna-doctor explain --blocked # the newest request the gateway refused
luna-doctor explain --slowest --last 5  # the five longest by duration
luna-doctor watch             # during an incident: one line per state change, summary on Ctrl-C
luna-doctor analyze --for-issue  # one redacted, datable block for a support ticket
luna-doctor capacity          # the account's plan and priority concurrency (cached)
luna-doctor fix               # per-agent copy-paste config, from the evidence
luna-doctor fix --agent pi    # …or one agent at a time (pi | claude-code | codex | swamp)
luna-doctor chart --since 24h # SVG: one stacked-area graph of concurrent requests
luna-doctor chart --since 24h --open  # …and open it in your browser

Exit codes: 0 no pressure, 1 pressure found, 2 no data. These codes are usable from a shell prompt, a skill, or cron. explain follows the same codes for the one request it explains: 1 when that request was refused or stalled, 0 when it was served.

How it works

One report, two renderers

discover, analyze and capacity all build the same Report: a headline, sections of label: value rows, adjustments and a footnote. The report goes to one of two renderers. plain is undecorated text: no ANSI, no emoji, and no column padding. A value longer than its column therefore cannot break the layout. That is why output survives a narrow terminal, a pipe, or a chat app that wraps at 40 characters. pretty draws the same rows at a terminal, with color, a rule under each section and a moon.

--style is on discover, analyze, capacity and chart. auto is the default. It picks pretty when stdout is a terminal and NO_COLOR is unset, and plain otherwise. --style pretty asks for decoration even down a pipe. NO_COLOR still wins over it. CLICOLOR_FORCE colors a pipe that otherwise stays plain. --json is never styled: no Style is constructed on that path at all. chart takes the flag for consistency, but its console output is four plain lines. Those lines give the two paths, the counts behind the picture and the source of the drawn rule. The picture is the SVG.

--copy is on discover, analyze, capacity and fix. It puts the printed text on the clipboard too, as plain text. The tool uses the first of pbcopy, wl-copy, xclip or xsel that it finds on PATH. Stdout does not change, so a machine with no clipboard tool loses nothing. stderr says so, and never as an error.

The attribution line

The default run and analyze open with one line. That line answers a question that an error string cannot answer. Ask: which wall did this traffic hit? The wall is this machine, the account, or the gateway. The verdict word is one of you, your org, the gateway, not concurrency, or unknown.

The line leads the report. Every later row is judged against the wall that the line names. A report misleads when it confuses one wall with another. If a gateway fault looks like your own traffic, you lower a setting that was never the problem.

analyze: what your machine was doing

The command reconstructs every model request from the pi and claude-code log stores. It classifies each outcome against measured gateway behaviour: admission timeouts, silent background demotions, upstream errors, concurrency rejections that surface as 429s, and more. Thresholds (admission deadlines, idle budgets) are measured per model, not hardcoded. The report carries each model's observed deadline with its sample count. It does not lend one model's number to another.

The combined snapshot (metrics.snapshot in the --json envelope) reads one store further. codex rollouts are parsed into requests and counted as measured, per model and per harness. codex's declared configuration keys join the catalog beside their siblings. But codex requests do not enter the outcome classes above, which stay gated to the harnesses that --harness names (pi and claude-code by default). Token totals stay per harness and the tool never merges them. codex's input_tokens includes its cached tokens, while pi's input excludes cacheRead. One merged "input tokens" number is therefore a silent wrong answer.

--plan <plan name or number> declares the account's priority concurrency for that run. It is a declaration and never a measurement. The tool uses it only when the account's own capacity payload carries no number. If the account does report one, the declaration is set aside and the report says so. Account values are labelled (account), and plan-derived ones (declared plan <name>). A value that neither source supplies is unknown, with the command that fetches the account half (luna-doctor capacity).

fix takes the same --plan declaration, so the tool judges the advice and the analysis against the same number.

--baseline prev: this window against the one before it

analyze --baseline prev compares the window with the window before it. The second window has the same length and comes from the same logs. The report prints a change: section:

blocked at admission: 12 -> 3

If either window holds fewer requests than the sample floor, the verdict is inconclusive. A direction that rests on a few requests is a coincidence with a number attached.

The section never claims a cause. It places two windows of the same logs side by side. The report does not say that your change made the difference.

fix: what to change, with copy-paste you can audit

Prints one block per agent: the exact file, the snippet, and a command that backs the file up, makes sure that the file is still valid, merges the key, and prints ok. It never writes anything itself. The tool made sure that every key it emits is in the installed harness bundles or their shipped docs. It does not emit a key that it cannot make sure of.

The fix output ends with a way to make sure that the change worked. Restart the harness. Then run analyze --baseline prev and read the change: section. Success looks like a lower blocked-at-admission count over a window wide enough to clear the sample floor.

explain: why was that one request slow

analyze describes the window. explain describes one request in that window. The report gives the start and the end of the request, the length of the wait, and the result. It gives the number of requests that this machine had in flight when the request started, and the peak during the request. It also says whether the same model served another request inside that span.

The headline names the owner of the wait. The owner is this machine, the shared pool of the account, the gateway, or no wall at all. Each number carries a label that gives its owner.

If you give no flag, explain selects the request that went wrong, which is a refused request or a stalled request. --blocked selects the newest refused request. --slowest selects by duration only. A bare id selects one request by response id, session id, or run id.

watch: the incident, live

watch reads the last 15 minutes on a fixed interval. It prints one line for each change of state. The line gives the pressure classes that moved, the peak that this machine held, and the occupancy of the account when a capacity sample covers that moment.

On a terminal, watch redraws a frame. Through a pipe, it adds ASCII lines and never sends cursor control codes. Ctrl-C prints a summary of three lines and exits with the verdict of the last sample. --count N and --once stop without a key press. --json prints one object for each sample, for a script.

analyze --for-issue: a block that you can send to someone

--for-issue replaces the report with one block. The block is safe to paste. It gives the tool version and the log schema, the window in UTC, and the stores that the run read. It gives the attribution verdict, the headline, and the rows with their labels.

The tool redacts home paths, email addresses, and long opaque tokens before it writes the block. You can put the block in a support ticket or an issue, and the block does not carry the layout of your machine.

chart: one picture

Seven days of concurrent requests: the stack peaks above the dashed priority-concurrency rule, and red flare ticks mark requests the gateway rejected

Seven days of generated example traffic. The stack is the concurrent requests in flight, split by model and by scheduling class. The dashed rule is a declared width, not a measured one. The open circle is the peak. Each red tick on the top edge is a request that the gateway rejected. The thin stretches are the night.

One day of the same example: a burst crosses the rule and five requests were refused

The same store, for the last day only. A burst crosses the declared width, and five requests were refused at admission. A day with a busy afternoon and a quiet night is what the tool usually has to describe.

Both pictures come from a synthetic log store, not from an account and not from a real session. They carry a declared width, so no plan and no account number appears in them. Run python3 scripts/make_demo_charts.py to write them again. The script generates the store from an hour-of-week rhythm, a mix of models and tiers, and a small share of refusals, then points the chart at it and leaves the capacity cache empty, so no account value can reach the picture.

A standalone SVG opens anywhere. It has no embedded or remote fonts, so the text names a system font stack. It has no scripts and no CDN. A PNG is written beside it under the same stem, and that PNG is the inline image, pasteable into a chat, a note or a doc. --no-image suppresses it. The graph is one stacked-area graph: a single time axis and a single stack. The height of the stack is the account's total concurrent inference requests. The stack is layered by band. A band is one model's traffic from one agent in one scheduling class (blues for priority, violets for background). A band is named for the agent that it belongs to ("glm-5.3 · pi") whenever more than one agent has traffic. A grey outline carries the busiest moment per column. An open circle marks the exact peak. Red flare ticks hang where the gateway rejected a request. The account's priority concurrency is one dashed rule, drawn from the capacity API's cached account value, or declared with --plan when the account has no number. The rule is labelled with its source, so a fetched value can never be mistaken for a declared one. The footer carries the provenance: what was measured, what was declared.

The picture deliberately carries no universal admission deadline, because the deadline is per model. It carries no advisory concurrency figure, because advice that depends on the plan belongs to analyze. It carries no latency picture, because latency lives in analyze.

Use it as an agent skill

The tool explains one incident. The skill is what an agent reads, so that the agent reaches for the right command before it theorises.

skills/luna-doctor/SKILL.md is a router: the invariants that must not be broken, a table that turns a symptom into the file that answers it, the command index with the flags an agent actually types, and the exit codes. The nine files under skills/luna-doctor/references/ hold the depth. An agent loads one at a time:

file what it answers
playbooks/slow-right-now.md the incident loop, and how far back to widen
playbooks/one-bad-request.md explain: which request, and whose wait
playbooks/too-wide.md many requests at once, and whose wall it was
playbooks/after-the-fix.md did the change work
playbooks/share-evidence.md the block to paste, and watch live
outcomes.md every outcome id, verdict word and metric
gateway-contract.md the measured constants, and what these logs cannot see
fix-families.md how to read fix, and the sizing family
chart-anatomy.md reading the picture above

Every playbook ends in a verdict, so an agent that loads one cannot finish without saying whose wall it was, which number belongs to whom, and what to change. The catalogues are there because the same question keeps arriving in different words: which outcome id, whose capacity number, which header lies.

Add it to your agent:

npx skills add jesserobbins/luna-doctor

The skill is only as good as its agreement with the CLI, so that agreement is a test rather than a review note: tests/test_skill_package.py parses every subcommand's --help and fails if any skill file names a flag the parser does not carry, links a path that resolves nowhere, names a command that does not exist, or pushes the router past its line and word budget.

What it reads

Read-only, and offline by default for discover / analyze / fix / chart:

harness store
pi ~/.pi/agent/sessions/**/*.jsonl
claude-code ~/.claude/projects/**/*.jsonl
codex ~/.codex/sessions/**/*.jsonl: parsed into requests and counted as measured per model in the combined snapshot (compressed *.jsonl.zst rollouts are skipped because the Python 3.9 stdlib has no zstd), but not classified into the outcome classes above
swamp only a repository you name: --swamp-root DIR reads that repo's .swamp/run_tracker.db, and swamp is reported while a run overlapped the window
agentsview the local agentsview command, when it is installed: discover reports how many projects and sessions it holds, through one agentsview projects --json query. It is a second view of session history, never a log store, and the reports still read the harness logs. No daemon, no serve, no sync

Swamp is opt-in. Swamp keeps run history per repository, in <repo>/.swamp/run_tracker.db, and nothing on the machine lists the repositories a person uses swamp in: ~/.swamp holds the toolchain, and ~/.config/swamp/workspaces.json is a list you fill in. So the tool never looks for a swamp repo. Name one with --swamp-root DIR and that repository's own tracker decides: a run that started before the window closed and finished after it opened was in flight during the window. A store full of old runs is installed, not running, and the report says nothing about it. Installed is not traffic. The database is opened read-only, and swamp is never spawned.

When swamp does appear, the report describes its concurrency as configured (its extract and verify configuration, and where they came from). It never describes that concurrency as measured.

luna-doctor capacity serves a disk cache of the account's plan, priority concurrency and overflow capacity by default. The report keeps the two pools apart. The priority pool is the account's main width. The overflow pool holds the requests that a background tier takes, so a demoted request waits there. Each pool has its own occupancy and its own 7-day and 30-day rollups. The report never adds one pool to the other. It serves a cached payload even when that payload is stale, and the network stays untouched. Two flags can fetch, and each one is a single authenticated GET: capacity --online refreshes the capacity cache (5 minutes), and analyze --online / fix --online refresh the 24-hour model catalog at ~/.luna-doctor/catalog.json. Both use the routing key that lunaroute login already stored. Anything that cannot be fetched is reported as unknown, and never as an error.

Limits worth knowing

  • Log records capture when a request started and when its record was written. They do not record time to first token. So analyze measures end-to-end latency and classifies outcomes. The wait before headers is not in these logs.
  • Error records zero their token usage, so input size is unavailable on failures.
  • Concurrency reconstructed from one machine is a lower bound. Other machines and other tenants are invisible.

Repo layout

path what
luna_doctor/cli.py the CLI, the log readers and the analyser
luna_doctor/_fix_block.py the fix command, kept apart so you can read its snippets on their own and make sure that they are right
luna_doctor/_chart_block.py the chart command
luna_doctor/report.py the report model and its plain renderer
luna_doctor/render_pretty.py the decorated renderer over the same report
luna_doctor/style.py the single decoration gate: color, emoji, motion
luna_doctor/scene.py the chart's shape list, and the SVG renderer over it
luna_doctor/png.py the raster renderer over the same scene
luna_doctor/font.py the hand-drawn 5x7 bitmap font and its ASCII folding
bin/luna-doctor launcher for the no-packaging path. It resolves its own symlink and imports the package
skills/luna-doctor/ the agent skill: a router, five playbooks, four catalogues
assets/ the two chart images above, each one written by luna-doctor chart, and the social preview, written by python3 scripts/titlecard.py --moon <screenshot>

The package ships luna_doctor/, which is the CLI and its sibling modules. cli loads _fix_block and _chart_block off disk from its own directory, so a partial install is the one thing that breaks fix and chart. The skill, the launcher in bin/ and the chart images stay in the repository. The skill is not inside the wheel yet, so an agent that installs the tool from PyPI still reads skills/luna-doctor/SKILL.md from this repository.

How this project is built

This project is developed using a Greenroom. The Greenroom is this public repo beside a private one that holds the design docs, drafts and review notes. That way, work can be shaped before it takes the stage. If you want to read the private greenroom repo, reach out and ask.

Greenroom is a skill you can use on your own projects:

Versioning

Semantic versioning with prerelease identifiers (0.1.1-alpha.1, 0.1.1-beta.1, …), single source of truth:

what where
version number VERSION, mirrored in luna_doctor/cli.py
history CHANGELOG.md (Keep a Changelog)
releases annotated git tags, vX.Y.Z and vX.Y.Z-prerelease (e.g. v0.1.1-alpha.1), published to PyPI by scripts/publish.sh
JSON document shape "schema": 4 in every --json envelope: bumped independently when the shape changes
measured gateway facts "contract": {...} in every --json envelope

Prereleases publish to PyPI. The index keeps every uploaded file forever, and a bad one is yanked rather than deleted, so pin an exact version in automation.

Publishing needs a PyPI token in PYPI_TOKEN, or in ~/.config/luna-doctor/pypi-token. scripts/publish.sh builds the tagged release, uploads it and reads the index back to confirm that the version is live. It publishes nothing without a tag and a clean tree.

Every --json document carries schema, version, and command. The reporting commands (discover, analyze, fix, chart, capacity) also carry contract, so a saved report stays readable after the tool moves on. While the project is prerelease, schema is bumped freely, because the envelope has no stable consumers to protect yet. Usage errors print to stderr and exit 2 with no JSON document.

Bumping a version updates the number in both places, opens a dated changelog section, and prints the tag and push commands. It never pushes:

scripts/release.sh patch       # 0.1.1 -> 0.1.2
scripts/release.sh 0.1.1-beta.1 # explicit

Tests are stdlib-only and read-only (they never open live sessions on the gateway):

python3 -m unittest discover -s tests -v

Metadata

Release files for luna-doctor 0.3.0a6

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for luna-doctor 0.3.0a6
File Size Uploaded Provenance Actions
luna_doctor-0.3.0a6.tar.gz 290.5 kB Without provenance Details for luna_doctor-0.3.0a6.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for luna-doctor 0.3.0a6
File Interpreter ABI Platform Actions
luna_doctor-0.3.0a6-py3-none-any.whl Python 3 none any Details for luna_doctor-0.3.0a6-py3-none-any.whl

Total release size: 473.9 kB

Release files / luna_doctor-0.3.0a6.tar.gz

Download URL luna_doctor-0.3.0a6.tar.gz
Size 290.5 kB
Tags Source
SHA-256 checksum
How to use checksums
64bf0a7b228d196b111638f05263e12b007ac6f1c90da19f8ab4110ed4bb0c7a
BLAKE2b-256 checksum
How to use checksums
d293511e532708c36f99a6a671bf6dba492031db86e23ff3e1a71112d4292f59
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / luna_doctor-0.3.0a6-py3-none-any.whl

Download URL luna_doctor-0.3.0a6-py3-none-any.whl
Size 183.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9510c5f3878ad9523732f732327ca2ba3fd06563825255e1c7c22eb8ed499029
BLAKE2b-256 checksum
How to use checksums
6c262d821882d4e6e20992503164d8d523d14eeb1ce439f32293c75c9043810a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for luna-doctor 0.3.0a6

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page