# omni-cswap-setup

Provision a machine (laptop or headless server) to run **Omnigent** with **cswap** multi-account
load balancing, auto-rotation, limit rescue, and usage reporting.

Single operator, multiple own accounts, multiple machines. Every machine runs the same stack;
what differs is its **pool** (which accounts it may use) and whether it's **headless**.

## Provision a machine

**From a URL** (nothing to clone — see [Hosting](#hosting)):

```bash
# headless server, autonomous — unattended, ~3 min
curl -fsSL https://setup.lepton.software/install.sh | bash -s -- --headless --bypass

# interactive, including the account logins — note `bash <(…)`, not a pipe
bash <(curl -fsSL https://setup.lepton.software/install.sh)

# verify anything, change nothing
curl -fsSL https://setup.lepton.software/install.sh | bash -s -- --check
```

If you host it elsewhere, tell the script where its `machinery/` lives:
`--base-url https://HOST` (env `OMNI_BASE_URL`). Default: `https://setup.lepton.software`.

**From a checkout** (identical behaviour; uses the local `machinery/` instead of fetching):

```bash
git clone <this repo> && cd omni-cswap-setup
./install.sh --pool "3 4"                       # interactive laptop
./install.sh --headless --bypass --pool "6 7"   # headless server
./install.sh --check
```

## Hosting

Live at **https://setup.lepton.software** — served by a `omni-setup-web` systemd `--user`
service on backstage (192.168.2.96:8090), routed through Traefik on 192.168.1.59
(`~/services/traefik/dynamic/setup.yml`), TLS via the `cloudflare` resolver. To redeploy after
editing the installer, from backstage:

```bash
cd ~/omni-cswap-setup && git pull   # or rsync the repo up
./publish.sh ~/omni-setup-web       # restage the web root the service serves
```

`./publish.sh` builds a clean release tree (`install.sh` + `machinery/` + `MANIFEST.sha256`)
and copies it to a web root — local or remote:

```bash
./publish.sh ~/omni-setup-web                          # local web root (backstage)
./publish.sh lepton@192.168.2.96:~/omni-setup-web      # remote (rsync)
./publish.sh --serve                                   # throwaway server, for testing
```

Any static host works — the installer only needs `install.sh` and `machinery/` reachable over
HTTPS at the base URL. To verify a download out of band:

```bash
curl -fsSL https://setup.lepton.software/MANIFEST.sha256 -o MANIFEST.sha256 && sha256sum -c MANIFEST.sha256
```

## What gets installed

| | |
|---|---|
| `uv` | Python tool manager (Astral installer) |
| `tmux` | Omnigent runs each session in a tmux pane; rescue drives them |
| `bubblewrap` | Linux sandbox — **Omnigent's own setup installs it and asks for confirmation**; the installer only reports status |
| Claude Code CLI | native installer (`claude.ai/install.sh`) |
| `omnigent` | `uv tool install omnigent` |
| `claude-swap` (`cswap`) | `uv tool install claude-swap` |
| `~/.omni-cswap/` | shim, picker, pretrust, rescue, reporter |
| 4 services | `cswap-auto`, `cswap-rescue`, `cswap-report`, `omnigent-host` (launchd on macOS, systemd `--user` on Linux) |

## How a session gets its account

```
Omnigent spawns `claude`
  └─ resolves to ~/.omni-cswap/claude (shim dir is FIRST on PATH)
       ├─ utility invocation (auth/config/mcp/…)? → real claude, default login
       ├─ pick_account.py → emptiest healthy account in this machine's pool (sticky per session)
       ├─ pretrust.py → pre-accept the folder-trust dialog in that account's config dir
       ├─ ~/.omni-cswap/bypass present? → append --permission-mode bypassPermissions
       └─ exec cswap run <acct> --share-history -- <args>
```

`--share-history` is the linchpin: it keeps `projects/` pointed at `~/.claude` so Omnigent's
cold-resume finds the transcript regardless of which account the session landed on.

## The invariants (each one was learned the hard way)

1. **OAuth refresh tokens are single-use.** Log in per machine. **Never** `cswap export` on one
   box and `import` on another — both copies race to refresh the same grant and mutually log each
   other out. This is why there's no credential-copying step in the installer.
2. **Give every machine a disjoint `pool`.** Same reason as #1: two machines drawing the same slot
   will kill it. The pool file (`~/.omni-cswap/pool`, one account number per line) is the *only*
   thing preventing that. No pool = any account = eventual token death.
3. **Never probe an account that has a live session.** The picker reads cached usage from
   `cswap list --json`; it never forces a refresh on an account in use.
4. **`set -u` in the shim** — every var must be defaulted (`${VAR:-}`). An unbound variable there
   takes down every session on the box at once.
5. **bootout-then-bootstrap, never `kickstart`.** `kickstart` reuses the *old* plist, so config
   changes silently don't apply. The installer always boots out first.
6. **`cswap auto` reads settings at start.** Changing `autoswitch.*` requires restarting the
   service; the installer does this in the right order.
7. **Rescue is tmux-based, not API-based.** Omnigent's API bearer token expires (~8h); tmux
   sockets and transcripts don't. That's why `rescue.py` drives panes.
8. **Gates persist once accepted**, per config dir. Each cswap account has its own config dir, so
   `pretrust.py` must seed trust per account — `bypassPermissions` does *not* skip the trust dialog.

## Adding accounts

The installer loops interactively: it drops you into `claude /login` (with the shim removed from
PATH so it hits the real binary and default config), then runs `cswap add`. Repeat per account.

On a headless box use `--headless`. To add an account there later, SSH in with port forwarding and
run the login on that machine — again, don't copy credentials in.

```bash
cswap ls                    # state of all slots
cswap add --slot 5          # register the currently-logged-in account into slot 5
cswap disable 6             # hold a slot out of rotation without removing it
```

If `cswap ls` says *"Account-N and Account-M hold the same credential"*, one slot's backup was
overwritten: log in with the missing account and `cswap add --slot N` to repair it.

## Reporting

`report_usage.py` snapshots `cswap list --json` every 15 min into `~/.omni-cswap/usage/`
(`usage.jsonl` + `usage.db`). See **[REPORTING.md](REPORTING.md)** for the schema, example
queries, and how to wire the remote push — and for what this data **cannot** tell you.

## Tunables

| Env | Default | Effect |
|---|---|---|
| `OMNI_CSWAP_THRESHOLD` | `90` | picker: skip accounts at/over this % |
| `OMNI_CSWAP_EXCLUDE` | — | picker: comma-separated accounts to skip |
| `OMNI_CSWAP_MODEL` | — | picker: also weigh this model's scoped limit (e.g. `Fable`) |
| `OMNI_RESCUE_INTERVAL` | `30` | rescue: seconds between pane scans |
| `OMNI_RESCUE_COOLDOWN` | `600` | rescue: min seconds between rescues of one pane |
| `OMNI_REPORT_INTERVAL` | `900` | reporter: seconds between snapshots |
| `OMNI_MACHINE_ID` | hostname | reporter: label for this machine |
| `OMNI_REPORT_DSN` | — | reporter: remote DSN (only if you wire `push_to_remote`) |

## Troubleshooting

```bash
./install.sh --check                              # binaries, pool, shim resolution, accounts
OMNI_CSWAP_DRYRUN=1 claude                        # which account would this dir get?
tail -f ~/.omni-cswap/{shim,rescue,cswap-auto,report}.log
launchctl print gui/$UID/software.lepton.cswap-auto | head -20   # macOS
systemctl --user status cswap-auto                               # Linux
```

**Sessions all land on one account** — no `pool` file, or the others are over threshold
(`cswap ls`).
**Shim not used** — `command -v claude` must show `~/.omni-cswap/claude`; the service PATH must
list the shim dir first.
**Linux services die on logout** — `sudo loginctl enable-linger $USER`.
