clauth 0.10.0

Manage multiple Claude Code accounts, monitor 5h/7d usage with configurable auto-switch and delegation with an MCP plugin. CLI + TUI.
# `clauth daemon` — headless scheduler + status feed

`clauth daemon` runs the same background refresher the TUI runs
(`spawn_refresher`) with no UI: refresh usage, rotate expiring tokens, execute
the fallback chain's auto-switches, and publish `~/.clauth/status.json` for
external readers. `clauth status --json` prints the same shape single-shot,
no daemon required. This document is the read contract for both.

Scope note: the daemon carries **no external mutation surface** — no socket,
no config endpoint. Anything that needs eyes (a diverged live login, a manual
Claude Code `/login`, an unprovable identity) is refused and logged; the TUI
stays the only resolution surface.

## Process model

- **Singleton**: one advisory lock (`~/.clauth/clauthd.lock`) held for the
  process lifetime. A second `clauth daemon` blocks in standby and takes over
  the moment the holder exits — a dead holder's flock auto-releases, so a
  supervisor (`launchd`/`systemd`) with restart-on-crash keeps exactly one
  scheduler alive without pidfile bookkeeping.
- **Watchdog**: the state flock has no deadline and a switch shells out inside
  it, so a wedged tick can freeze the single-threaded loop. If no tick
  completes within 60 s the daemon `abort()`s for a clean supervisor restart.
- **Log hygiene**: every daemon-visible stderr line carries an ISO-8601 UTC
  prefix (enabled only in daemon mode — interactive stderr stays bare), and
  `~/.clauth/daemon.log` is size-capped in place when a supervisor points
  stderr at it. The in-place trim is only sound for an APPEND-mode fd: use
  launchd `StandardErrorPath` or systemd `StandardError=append:...` — a
  non-append redirect (`file:`, a plain `>`) keeps its own offset, so the
  next write after a trim leaves a sparse NUL hole and the size cap is
  defeated.
- **Dual-scheduler dedup (TUI stand-down)**: a TUI opened alongside the daemon
  probes the singleton lock + feed freshness every tick and stands its own
  refresher down while the daemon is live — rendering from the daemon's disk
  caches instead of double-polling the usage API, double-rotating the
  single-use refresh chain, or re-deciding its switches. It re-arms within one
  tick of the daemon dying (lock released) or wedging (feed stale).

## `~/.clauth/status.json`

Written each scheduler tick and immediately after a switch lands. Atomic
(`tmp` + rename into place), `0600`. **Never carries a token, secret, or
key** — names, tiers, percentages, timestamps only.

```json
{
  "schema": 1,
  "generated_at": "2026-07-03T19:04:40+00:00",
  "active_profile": "kitty",
  "pending_switch": null,
  "wrap_off": false,
  "refresh_interval_ms": 300000,
  "profiles": [
    {
      "name": "kitty",
      "active": true,
      "provider": "anthropic",
      "base_url": null,
      "tier": "Max 5x",
      "has_live_session": true,
      "auth_status": "ok",
      "fetch_status": "Fresh",
      "stale": false,
      "fetched_at": "2026-07-03T19:04:20+00:00",
      "next_refresh_at": "2026-07-03T19:09:20+00:00",
      "auto_start": true,
      "bell_threshold": 90,
      "fallback": { "position": 1, "threshold": 95.0, "armed": true },
      "windows": [
        { "label": "5h",      "utilization_pct": 42.0, "resets_at": "2026-07-03T23:00:00+00:00" },
        { "label": "7d",      "utilization_pct": 18.0, "resets_at": "2026-07-08T17:00:00+00:00" },
        { "label": "7d Opus", "utilization_pct": 30.0, "resets_at": "2026-07-08T17:00:00+00:00" }
      ],
      "third_party": null
    }
  ]
}
```

### Field notes

| Field | Semantics |
|---|---|
| `schema` | Integer, currently `1`. Bumped ONLY on a breaking change; additive fields do not bump it (evolution rule below). |
| `generated_at` | Write stamp, ISO-8601 UTC with an explicit `+00:00` offset (all timestamps are; parse the offset, never key on a `Z` suffix — the writer does not emit one). Readers derive staleness from it: a stamp much older than `refresh_interval_ms` means the daemon is gone/stuck — show last-known data with a stale cue, never spin. |
| `active_profile` | The profile whose credentials are currently installed, else `null`. |
| `pending_switch` | A switch the daemon has accepted but not yet applied (`"<name>"`), else `null`. Exists so readers can show in-flight truth instead of a timing heuristic. Always `null` from the single-shot CLI. |
| `wrap_off` | The fallback chain's stop-vs-stay-on-last flag, verbatim from state. |
| `profiles[].provider` | `"anthropic"` for OAuth profiles, else the recognised provider's display name. |
| `profiles[].tier` | Plan label (`"Max 5x"`…), `null` when unknown. Opaque display string. |
| `profiles[].auth_status` | `"ok" \| "expiring" \| "broken"`. `broken` = last refresh rejected as revoked/invalid → excluded from fallback walks, refused as a switch target. `expiring` = past expiry, not yet refreshed. `broken` outranks `expiring`. Absent ⇒ `"ok"`. |
| `profiles[].fetch_status` | `"Fresh" \| "Cached" \| "Failed" \| "RateLimited"` — the usage fetch's last outcome, so readers can distinguish live bars from last-known. `Failed`/`RateLimited` come only from a live daemon's OAuth fetch leg; api-key profiles (and any name the live stores don't carry yet) derive `Fresh`/`Cached` from their own cache's mtime instead — an api-key profile with a warm cache is never reported as unfetched. `null` = no cache at all (genuinely never fetched). |
| `profiles[].stale` | `bool` (additive, schema stays 1; absent ⇒ `false`). `true` when the daemon distrusts this reading as a **deep-slot stuck `RateLimited`**: `fetch_status == "RateLimited"` AND the consecutive-429 streak has passed the active-retry cap, so the `/usage` throttle never drained and no `Fresh` read is coming. This is the same judgment the daemon's auto-switch acts on — a stuck-RateLimited active bypasses the "only act on a Fresh read" gate so the chain rotates away instead of wedging (the `RateLimited` analogue of the `auth_status: "broken"` bypass); the switch still requires the active's last-known usage to be genuinely spent, so a throttle blip with headroom stays put. Readers should dim the meter / show a "stuck" cue rather than render the frozen number as current truth. `false` for a shallow/transient `RateLimited`, for every non-`RateLimited` status, and **always** for the single-shot `clauth status --json` (no daemon, no streak history). |
| `profiles[].fallback` | `null` when not in the chain; else 1-based `position`, `threshold` (%), `armed` (this member is the active one the auto-switch watches). |
| `profiles[].windows[]` | `label` is **derived, not an enum**: `"5h"` and `"7d"` always; the third is a plan-tier label (`"7d Opus"`…). Treat labels as opaque display strings, never keys to switch on. `utilization_pct` 0–100 float; `resets_at` nullable. |
| `profiles[].third_party` | `{ "available": bool }` for api-key profiles once probed, else `null` — including an api-key profile whose provider has never been reached (no cache yet). Plain reachability; structured balances deliberately deferred. |

### Evolution rule (the load-bearing part)

- **Writers**: additive only under the same `schema` — new fields may appear,
  existing fields never change type/meaning. A breaking change bumps `schema`.
- **Readers**: ignore unknown fields; default absent optional fields (absent
  `auth_status` ⇒ `"ok"`); refuse only on `schema` greater than what they
  know, showing "daemon newer than me" rather than garbage.

## `clauth status --json`

Same schema, produced single-shot from the on-disk caches with no daemon and
no network fetch: `pending_switch` is always `null`, `generated_at` is the
print stamp, and freshness/next-refresh derive from each profile's usage-cache
mtime. One code path builds both (`daemon::status_json::build_status`), so the
key SHAPE cannot drift between the daemon feed and the CLI snapshot. Values can:
the single-shot form derives `fetch_status` from cache mtimes, so it only ever
reports `Fresh`/`Cached`/`null` — a profile the live daemon shows as `Failed` or
`RateLimited` reads as `Cached` here at the same instant. Poll the feed, not the
CLI, when the fetch outcome matters.