Run halton-meter doctor first — it covers the most common failure modes with
copy-pasteable fixes. If doctor doesn’t resolve the issue, email
operator@haltonlabs.com with the output of
halton-meter doctor --json attached.
Install fails with “Could not find a suitable TLS CA certificate bundle”
Symptom: pipx install halton-meter / uv tool install halton-meter /
uvx halton-meter fails with one of:
ERROR: Could not find a suitable TLS CA certificate bundle
SSL: CERTIFICATE_VERIFY_FAILED
ssl.SSLError: [SSL] ... CA cert missing
Cause: A previous Halton Meter (or any mitm tool) left CA env vars exported in your shell pointing at a certifi bundle inside a deleted virtualenv. The installer can’t reach PyPI through them.
Fix: Unset the env vars in the current shell, then retry:
$ unset CURL_CA_BUNDLE SSL_CERT_FILE REQUESTS_CA_BUNDLE NODE_EXTRA_CA_CERTS \
GIT_SSL_CAINFO AWS_CA_BUNDLE HTTPS_PROXY HTTP_PROXY NO_PROXY
$ uvx halton-meter --version # or your install command of choice If uvx still serves a stale wheel, force a fresh fetch:
uv cache clean halton-meter && uvx halton-meter --version halton-meter cloud status shows PAUSED — what to do
As of v0.2.8, pause classification is strict. The recovery depends on the
classification — guessing wrong (and re-running cloud connect instead of
cloud resume) revokes a still-valid key.
paused_reason | What it means | Recover with |
|---|---|---|
paused_unauthorised | A real HTTP 401 on /v1/requests/batch. The token is genuinely invalid. | halton-meter cloud resume. If resume reports the token is still bad, then cloud connect to re-pair. |
paused_forbidden | HTTP 403 — the workspace owner removed this machine. | Ask the workspace owner to re-invite this machine, then halton-meter cloud resume. Do not cloud connect — that revokes the still-valid existing key. |
paused_manual | You ran halton-meter cloud pause. | halton-meter cloud resume clears it. |
last_error set, no pause | A transient error (5xx, timeout, DNS, 429, 422/400). Sync keeps retrying. | No action needed. Check ~/.halton-meter/daemon.err.log if it persists. |
$ halton-meter cloud status --json # confirm paused_reason
$ halton-meter cloud resume # whoami probe; clears pause if 200 resume calls GET /v1/daemon/whoami once with the stored token. On 200 it
clears the pause, counters, and last_error. On 401 it reports the key is
genuinely revoked — at which point cloud connect is the right next step.
Daemon exits immediately at startup with “loopback bind guard”
Symptom: halton-meter start returns to the prompt without an error
message; halton-meter status shows the daemon is not running; daemon log
contains:
SystemExit(1) — listen_host=0.0.0.0 is not a loopback address (refusing to bind)
Cause: v0.2.9 added a hard guard so the daemon’s mitmproxy and FastAPI
ports cannot bind to non-loopback addresses. The check fires on every
boot. It catches accidental 0.0.0.0 / [::] config — which would expose
captured request bodies to your LAN.
Fix (intended config): edit ~/.halton-meter/config.toml and set
listen_host = "127.0.0.1" and api_host = "127.0.0.1". Then
halton-meter start.
Override (container or VPN topology that legitimately needs non-loopback): set the environment variable before start and accept the warning logged on every boot.
$ HALTON_METER_ALLOW_NON_LOOPBACK=1 halton-meter start
⚠ bind-guard override active — daemon is reachable from non-loopback addresses Persist the override across reboots by adding HALTON_METER_ALLOW_NON_LOOPBACK=1
to the supervisor’s EnvironmentVariables (launchd plist on macOS, systemd
unit on Linux, Task Scheduler XML on Windows).
cloud.transport_error opaque or stuck retrying — placeholder base_url
Symptom: halton-meter cloud status shows last_error: cloud.transport_error
on every retry; the daemon never reaches the backend.
Cause: [cloud].base_url in ~/.halton-meter/config.toml contains a
placeholder TLD (.test, .example, .invalid, .local). These never
resolve; the retry loop burns through its budget on every cycle.
v0.2.5+: placeholder TLDs now raise immediately instead of waiting for
the full timeout — the error surfaces the base URL and the config file path
inline, so the cause is visible from cloud status.
v0.2.7+: the daemon auto-heals placeholder TLDs on next boot,
rewriting base_url to https://api.haltonmeter.com and logging the
rewrite once. You don’t need to do anything; restart the daemon:
halton-meter stop && halton-meter start For older daemons (pre-v0.2.7), edit ~/.halton-meter/config.toml
manually and set base_url = "https://api.haltonmeter.com".
Body capture skipped — “addon.body_capture.skip reason=body_too_large”
Symptom: ~/.halton-meter/daemon.out.log contains entries like
addon.body_capture.skip reason=body_too_large. The request still shows up
in halton-meter report (metadata captured: model, tokens, cost) but the
request body and response body are not stored locally.
Cause: v0.2.10 added a 4 MiB size gate on body capture to prevent memory spikes from large multi-modal payloads (PDF uploads, image batches, streaming Vision responses). Bodies above the gate are dropped at the proxy boundary; metadata still captures.
Not a problem. This is the intended behaviour. Cost and attribution are correct; only the raw request/response bytes are missing. If you need to inspect the body for a specific call, capture it through a separate debugging proxy (mitmweb) rather than raising the gate.
To audit how often the gate fires:
grep -c 'body_capture.skip' ~/.halton-meter/daemon.out.log Cursor / VSCode shows “unattributed” on first request after cold start
Symptom: The first request from a freshly-launched Cursor / VS Code /
Kiro / Zed / IntelliJ window lands in halton-meter report as
unattributed. Subsequent requests in the same window are attributed
correctly.
Cause (pre-v0.2.5): The edge process’s per-edge slug → abspath
registry was empty on first contact; the resolver’s Tier 4b / 4c looked up
a slug that didn’t exist yet.
v0.2.5+ fix: A lenient one-level scan of ~/Documents and ~ warms
the registry on Tier 4b / 4c miss. Cold-start rows are now attributed via
the recovery path; telemetry shows 4b_lenient or 4c_lenient in
attribution.tier_hit.
If you still see this on v0.2.5+: the project root isn’t under
~/Documents or ~. Add a .haltonrc (project: <slug>) to the
project root, or set HALTON_PROJECT=<slug> in the IDE’s launch env.
Backfill historical unattributed rows:
# Dry-run — prints what would change without writing
$ python -m halton_meter.scripts.backfill_misc_attribution
$ python -m halton_meter.scripts.backfill_body_paths
# Apply — rewrites rows in place (DB-only; safe to re-run)
$ python -m halton_meter.scripts.backfill_misc_attribution --apply
$ python -m halton_meter.scripts.backfill_body_paths --apply Browser sites break (NET::ERR_CERT_AUTHORITY_INVALID)
Symptom: Chrome or Safari shows a certificate error (NET::ERR_CERT_AUTHORITY_INVALID) on sites like claude.ai or any HTTPS site after init.
Cause: You are in --full mode (system proxy enabled). Chrome enforces Certificate Transparency (CT) on HSTS-pinned domains. When traffic is routed through the mitmproxy CA, Chrome may reject the cert — even if macOS’s Keychain trusts it — because the CA cert is self-signed and not CT-logged.
Fix (recommended): Switch to --apps mode, which covers IDEs and terminals without enabling the system proxy:
$ halton-meter init --apps After running this, restart your browser. Browser traffic will no longer go through the proxy (no cert errors), and your IDEs will still be metered.
Emergency rollback if browser is broken:
$ halton-meter reset-proxy # immediately disables system proxy
$ halton-meter init --apps # re-init in safe mode Alternative fix (keep --full, use at your own risk): Manually trust the mitmproxy CA in Chrome:
- Open
chrome://settings/certificates - Import
~/.mitmproxy/mitmproxy-ca-cert.peminto Authorities - Trust it for “Identifying websites”
This may still fail on CT-pinned domains. The --apps fix is cleaner.
git push fails with SSL certificate error
Symptom: git push (or git fetch, git clone) fails with:
fatal: unable to get local issuer certificate
or:
SSL certificate problem: unable to get local issuer certificate
Cause: Git (especially Homebrew-installed git linked against OpenSSL) reads GIT_SSL_CAINFO to find the CA bundle, not SSL_CERT_FILE. In versions before v0.1.5, GIT_SSL_CAINFO was not included in the injected env vars.
v0.1.5+ fix (permanent): This is already fixed. Re-run init to ensure the shell rc block is up to date:
$ halton-meter init --apps # or whichever mode you use Open a new terminal. The new rc block includes GIT_SSL_CAINFO, CURL_CA_BUNDLE, and AWS_CA_BUNDLE.
Immediate workaround (before re-init):
GIT_SSL_CAINFO="$SSL_CERT_FILE" git push Or use halton-meter run which always injects all nine env vars:
halton-meter run git push Affected tools and their env vars:
| Tool | Env var needed |
|---|---|
| git (OpenSSL) | GIT_SSL_CAINFO |
| curl | CURL_CA_BUNDLE |
| AWS CLI / Boto3 | AWS_CA_BUNDLE |
| Python requests / httpx | REQUESTS_CA_BUNDLE |
| Node.js | NODE_EXTRA_CA_CERTS |
| Python (generic SSL) | SSL_CERT_FILE |
All six are set by halton-meter run, and all six are exported in the shell rc block for --apps and --full modes.
No captures in halton-meter report
Symptom: halton-meter report shows “No captures yet” even though you’ve made API calls.
Start with a quick health check:
$ halton-meter status # is the daemon HEALTHY?
$ halton-meter doctor # any row amber/red? Cause 1: Daemon is not running.
$ halton-meter start
# then retry your API call Cause 2: env-only mode — you didn’t use halton-meter run.
In env-only mode, only commands launched with halton-meter run <cmd> are metered. A bare claude in your terminal is not captured.
$ halton-meter run claude # this is metered
$ claude # this is NOT metered in env-only mode Switch to --apps mode if you want bare terminal invocations to be metered automatically.
Cause 3: --apps mode, but you haven’t opened a new terminal since init.
The shell rc block (.zshrc/.bashrc) is sourced only in new shells. Your current terminal still has the old env. Open a new terminal tab or window. Verify the env is set:
$ echo $HTTPS_PROXY
# should print: http://127.0.0.1:8081 Cause 4: IDE is Spotlight/Dock-launched but hasn’t been restarted since init.
Windsurf, Cursor, VS Code, and similar Electron/Node IDEs inherit the launchctl user-domain env at launch time. If you opened the IDE before init --apps, it doesn’t have the proxy env.
Fix: quit the IDE completely (Cmd-Q), then relaunch it from Spotlight or the Dock. Do not relaunch from your terminal with code . — that inherits the terminal’s env, not launchctl’s.
To verify the IDE has the env: open the IDE’s built-in terminal and run echo $HTTPS_PROXY. It should print http://127.0.0.1:8081.
Cause 5: --full mode, but Node.js ignores the macOS system proxy.
Node.js (and therefore Claude Code, Cursor, Windsurf, and all Electron apps) does NOT read the macOS system proxy panel. It only respects HTTPS_PROXY env. The system proxy alone captures nothing from these tools. Switch to --apps mode:
halton-meter init --apps Cause 6: Process is hardened / uses a pinned CA.
Some apps (corporate security tools, hardened binaries) pin their CA bundle and ignore all env vars. These cannot be intercepted without modifying the binary. This is expected and not a bug.
Init self-test fails immediately
Symptom: During halton-meter init, the self-test reports “Connection refused” or fails within the first second.
Cause: The daemon process takes a few seconds to start and bind its port (cold-start can take 6–8 seconds on a slow disk). Old single-shot health checks returned failure before the daemon was ready.
v0.1.5+ fix: The self-test now retries every 500ms for up to 30 seconds. If you’re on a version before v0.1.5, upgrade:
$ pipx upgrade halton-meter
$ halton-meter init If the self-test still times out after 30 seconds:
$ halton-meter doctor --curl
# Look at the "daemon" and "port" rows Common additional causes: another process already holds port 8081, 8090, or 8765 (see Port 8081, 8090, or 8765 already in use); disk permissions issue on ~/.halton-meter/.
Check daemon logs:
tail -n 100 ~/.halton-meter/daemon.err.log Daemon won’t start / keeps respawning
Symptom: halton-meter status shows the daemon is not running, or launchctl list | grep halton shows repeated non-zero exits.
Step 1: Check logs
$ tail -n 100 ~/.halton-meter/daemon.err.log # structured JSON / console events
$ tail -n 100 ~/.halton-meter/edge.err.log # edge events Step 2: Try starting manually to see the error
$ halton-meter start
# wait 5 seconds, then:
$ halton-meter status Step 3: Check for port conflicts
$ lsof -nP -iTCP:8081 -sTCP:LISTEN # edge
$ lsof -nP -iTCP:8090 -sTCP:LISTEN # daemon mitmproxy
$ lsof -nP -iTCP:8765 -sTCP:LISTEN # FastAPI If something else is holding 8081, 8090, or 8765, port discovery in daemon/halton_meter/port_alloc.py renegotiates to a nearby fallback and persists the chosen tuple to ~/.halton-meter/effective-ports.json. Pin specific values in ~/.halton-meter/config.toml if you need stable ports.
Step 4: Stale launchctl env (recursive proxy)
If your logs show the daemon is connecting to itself (proxy loop), see Stale launchctl env.
Step 5: Full reset
$ halton-meter stop
$ halton-meter uninstall
$ halton-meter init --apps # or your preferred mode Stale launchctl env causing recursive proxy failure
Symptom: The daemon starts then immediately exits. Logs show connection errors to http://127.0.0.1:8081. halton-meter doctor shows HTTPS_PROXY set in the launchctl domain pointing at the edge port.
Cause: A previous --apps or --full install set HTTPS_PROXY in the launchctl user domain. The daemon spawned by launchd inherited this env var, causing it to try to route its own health check through itself — a proxy loop — leading to immediate failure and exit. launchd then respawned it, creating a loop.
Fix:
# Clear the stale launchctl env vars
$ launchctl unsetenv HTTPS_PROXY
$ launchctl unsetenv HTTP_PROXY
$ launchctl unsetenv NO_PROXY
$ launchctl unsetenv NODE_EXTRA_CA_CERTS
$ launchctl unsetenv SSL_CERT_FILE
$ launchctl unsetenv REQUESTS_CA_BUNDLE
$ launchctl unsetenv GIT_SSL_CAINFO
$ launchctl unsetenv CURL_CA_BUNDLE
$ launchctl unsetenv AWS_CA_BUNDLE
# Now re-init (env-only mode first to verify clean start)
$ halton-meter stop
$ halton-meter init
$ halton-meter status mitmproxy CA cert serial warning
Symptom: Warning message during init or in daemon logs:
CryptographyDeprecationWarning: Parsed a negative or zero serial number,
which is disallowed by RFC 5280.
Cause: An old mitmproxy CA cert exists in ~/.mitmproxy/ that was generated by an older version of mitmproxy with a non-positive serial number.
Fix: Delete the old cert directory and let init regenerate it:
$ halton-meter stop
$ sudo rm -rf ~/.mitmproxy
$ halton-meter init --apps # or your preferred mode The admin password dialog will appear again for the new cert trust step.
Port 8081, 8090, or 8765 already in use
Symptom: Init completes but halton-meter status shows the daemon is using a different port (e.g., 8081/8766). Or init fails because it can’t bind.
What happens automatically: Halton Meter renegotiates to nearby fallbacks via port_alloc.py. The chosen tuple is persisted to ~/.halton-meter/effective-ports.json and runtime.toml so every component (edge, watchdog, status, doctor) reads the same source of truth. If everything in range is busy, init fails.
Check which port the daemon is actually on:
$ halton-meter status
# Look for "proxy-port" and "api-port" rows The actual port is saved to ~/.halton-meter/runtime.toml and used by all commands automatically. You don’t need to configure anything — halton-meter run, halton-meter report, etc., all read the runtime config.
If you need port 8081 freed:
$ lsof -nP -iTCP:8081 -sTCP:LISTEN
# Identify the PID, then:
$ kill <PID>
$ halton-meter stop && halton-meter start Mode sentinel drift (status shows wrong mode)
Symptom: halton-meter status shows a different mode than you expect, or shows install-mode: gui when you think you’re in --full mode.
Cause: The mode sentinel file (~/.halton-meter/install-mode) and the actual system state (system proxy, launchctl env, shell rc) can drift if init was interrupted, or if you’re on a version before v0.1.5’s reconciler.
Fix: Re-run init for the mode you want. v0.1.5+ reconciles all four state surfaces (proxy, launchctl env, shell rc, sentinel) in every direction:
$ halton-meter init --apps # or your preferred mode The gui sentinel value is automatically migrated to full on next init. No manual file editing needed.
--non-interactive cannot elevate error
Symptom: Init fails with exit code 2 and a message like:
--non-interactive cannot elevate: osascript unavailable and sudo not pre-cached
Cause: You’re running halton-meter init --non-interactive (e.g., from a script or CI), but neither osascript (the macOS dialog) nor a pre-cached sudo credential is available.
Fix option 1 — run interactively:
halton-meter init --apps The macOS admin dialog appears. Type your password and click OK.
Fix option 2 — pre-cache sudo then run non-interactively:
$ sudo -v # enter your password to cache credentials
$ halton-meter init --apps --non-interactive The sudo -v credential cache lasts for a few minutes (configurable in /etc/sudoers). Run init immediately after.
Cert trust check fails (verify-cert returns non-zero)
Symptom: Init reports cert trust failure. halton-meter doctor shows the cert is not trusted.
What the check does: security verify-cert -c ~/.mitmproxy/mitmproxy-ca-cert.pem -p ssl — a real SecTrust evaluation with no override flags. Non-zero means the cert is genuinely not trusted by macOS.
Cause: You cancelled the macOS password dialog.
The cert was added to Keychain but not trusted. Rerun init and approve the dialog:
halton-meter init --apps Cause: Keychain entry exists but is untrusted (marked “Never Trust”).
- Open Keychain Access (
Cmd+Space→ “Keychain Access”) - Search for “mitmproxy”
- Double-click the cert → expand “Trust” → set “When using this certificate” to “Always Trust”
- Close (your password is requested to save)
Then verify:
$ security verify-cert -c ~/.mitmproxy/mitmproxy-ca-cert.pem -p ssl
$ echo $? # should print 0 Cause: Old cert in Keychain from a previous install.
The cert was regenerated (e.g., after sudo rm -rf ~/.mitmproxy) but Keychain still has the old one trusted while the new one is untrusted.
Fix: delete the old Keychain entry and rerun init:
# List all mitmproxy certs
$ security find-certificate -a -c mitmproxy
# Delete stale entries (one at a time if multiple)
$ security delete-certificate -c mitmproxy
# Then re-init
$ halton-meter init --apps launchd supervisor loop (daemon spawns, exits, repeats)
Symptom: The daemon process repeatedly starts and exits. launchctl list | grep halton shows a non-zero exit count that keeps climbing.
Diagnosis:
# Check exit reason
$ launchctl list com.haltonlabs.meter
# Look at LastExitStatus
# Check daemon log
$ cat ~/.halton-meter/daemon.err.log | tail -20 Cause 1: Preflight self-refusal loop (fixed in v0.1.5)
The daemon’s preflight check saw its own launchctl registration and refused to start (returned exit 1), causing launchd to respawn it — infinite loop.
Fix: upgrade to v0.1.5+:
$ pipx upgrade halton-meter
$ halton-meter uninstall
$ halton-meter init --apps In v0.1.5+, the daemon self-recognizes via XPC_SERVICE_NAME=com.haltonlabs.meter and skips the preflight check when running as the launchd service.
Cause 2: Stale launchctl HTTPS_PROXY env (proxy loop)
See Stale launchctl env.
Cause 3: Port permanently blocked
If every port in the discovery range (around 8081 / 8090 / 8765) is busy, the daemon fails at bind time and exits. Free one and run halton-meter stop && halton-meter start.
Captured traffic missing after reboot
Symptom: halton-meter report shows rows before the reboot but nothing after, even though the daemon appears healthy.
Most likely cause: The launchd plist is installed but env vars aren’t set in new shells.
After a reboot:
- Open a fresh terminal
- Check:
echo $HTTPS_PROXY - If empty and you’re in
--appsmode, source your rc manually:source ~/.zshrc(or~/.bashrc) - If still empty, re-run
halton-meter init --apps— the rc block may have been removed
Less common cause: launchd didn’t start the daemon plist after reboot (happens if the plist was installed while logged in as a different user, or if /Library/LaunchAgents vs ~/Library/LaunchAgents is wrong).
Check:
$ launchctl list | grep halton
# Should show com.haltonlabs.meter If absent:
$ halton-meter uninstall
$ halton-meter init --apps Claude Code (terminal-launched) not captured
Symptom: You run claude in a terminal and make API calls, but halton-meter report shows nothing.
| Mode | Expected behaviour | Fix |
|---|---|---|
| env-only | NOT captured unless you use halton-meter run claude | Use halton-meter run claude |
| apps | Captured IF the terminal was opened after init | Open a new terminal; verify echo $HTTPS_PROXY |
| full | Captured via env var (same as apps); system proxy doesn’t help Node.js | Open new terminal; verify env |
Quickest fix for any mode:
halton-meter run claude halton-meter run injects all nine env vars directly into Claude Code’s process at exec time — it always works regardless of mode.
Claude Code (IDE-launched) not captured
Symptom: Claude Code inside Windsurf, Cursor, or VS Code is not metered. halton-meter run claude works fine.
Cause: The IDE was launched before halton-meter init --apps ran, so it doesn’t have HTTPS_PROXY in its env. Or the IDE is launched from a terminal (code .) rather than from Spotlight/Dock, so it inherits the terminal’s pre-init env.
Fix:
- Ensure you’ve run
halton-meter init --apps(not justinit) - Quit the IDE completely: Cmd-Q (not just close the window)
- Relaunch from Spotlight (
Cmd-Space→ type the IDE name) or the Dock — NOT from your terminal - Inside the IDE, open the integrated terminal and run
echo $HTTPS_PROXY— it should printhttp://127.0.0.1:8081 - Make an API call, then check
halton-meter report
Why Spotlight/Dock vs terminal matters: launchctl setenv (which --apps mode uses) sets env vars in the launchd user domain. Apps launched by launchd (from Spotlight or Dock) inherit this domain. Apps launched from your terminal inherit the terminal’s env — which only has the vars if you’ve opened a new terminal after init.
Full uninstall / clean slate
If you also need a way back from a totally wiped machine, restoring rollups from a synced copy is the Cloud answer; locally the steps below are destructive.
If something is badly broken and you want to start over:
# Stop the daemon
$ halton-meter stop
# Remove the install (preserves db.sqlite by default)
$ halton-meter uninstall
# For a completely clean start (also deletes db.sqlite and logs):
$ halton-meter uninstall --purge --include-logs
$ sudo rm -rf ~/.mitmproxy
# Re-install
$ pipx reinstall halton-meter
$ halton-meter init --apps Command reference
# Install / reinstall / switch modes
halton-meter init # env-only (default)
halton-meter init --apps # apps mode
halton-meter init --full # full mode (system proxy)
halton-meter init --apps --no-shell-rc # apps but skip rc modification
halton-meter init --non-interactive # no GUI dialog; needs sudo pre-cached
# Run a command with metering env injected
halton-meter run <cmd> [args...] # exec cmd with all proxy env vars
halton-meter run -- <cmd> --flag # use -- to pass flags to the command
halton-meter run --shell # interactive metered subshell
# Daemon control
halton-meter start # start via launchd
halton-meter stop # stop
halton-meter stop && halton-meter start # restart (no dedicated subcommand)
# Status and diagnostics
halton-meter status # HEALTHY / INCONSISTENT / BROKEN
halton-meter status --json # machine-readable
halton-meter doctor # full diagnostic with copy-paste fixes
# Usage data
halton-meter report # show captured rows
halton-meter report --json # machine-readable
# Cleanup
halton-meter uninstall # remove plists, restore proxy state
halton-meter uninstall --purge # also delete config + sentinels
halton-meter uninstall --purge --include-logs # also delete db.sqlite
halton-meter reset-proxy # emergency: disable system proxy