Metadata-Version: 2.5
Name: nimbus-notify
Version: 1.7.1
Summary: Host broker that watches your AI coding sessions (Claude Code, Codex, Mistral Vibe) and pushes live status to a physical LED ring / e-ink device over a documented wire protocol
Project-URL: Homepage, https://github.com/ristllin/nimbus-notify
Project-URL: Repository, https://github.com/ristllin/nimbus-notify
Project-URL: Changelog, https://github.com/ristllin/nimbus-notify/blob/main/CHANGELOG.md
Author: Roy Darnell
License-Expression: MIT
License-File: LICENSE
Keywords: ble,claude-code,codex,hooks,led,mistral-vibe,notifier,serial
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Requires-Dist: aiohttp>=3.9
Requires-Dist: bleak>=0.22
Requires-Dist: pyserial>=3.5
Requires-Dist: watchdog>=4.0
Description-Content-Type: text/markdown

# nimbus-notify

A Python host broker that watches your AI coding-agent sessions — **Claude
Code**, **Codex**, and **Mistral Vibe** — via lightweight hooks, and pushes
their live status (running / waiting for you / needs approval / done /
errored) to a physical display device over serial (USB-CDC) or Bluetooth LE,
using a small documented binary protocol ([nsn](docs/protocol.md)).

If you've got several agent sessions going in parallel across different
terminals and projects, nimbus-notify gives you one glanceable place — an LED
ring, an e-ink panel, whatever device you point it at — to see which ones
are still working and which ones are waiting on you.

```
┌────────────┐   hooks    ┌───────────────┐   nsn wire protocol   ┌──────────┐
│ Claude Code│ ─────────▶ │               │   (serial or BLE)     │  status  │
│ Codex      │ ─────────▶ │ nsnotify-     │ ─────────────────────▶│  device  │
│ Mistral    │ ─────────▶ │  broker       │                       │(your own)│
│  Vibe      │            │               │                       │          │
└────────────┘            └───────────────┘                       └──────────┘
```

> **New here? → [QUICKSTART.md](QUICKSTART.md)** gets you from install to a reacting
> device in ~5 minutes (install · `nimbus-notify install-hooks` · connect · verify).
> The rest of this README is reference (services, bonding, transports, the protocol).

## Status

Beta. The core broker, all three harness adapters, and both transports are
implemented and tested (`python3 -m pytest`). This is a fresh split out of a
private monorepo into its own package — see [CHANGELOG.md](CHANGELOG.md).

## Compatible devices

nimbus-notify speaks a documented, transport-agnostic wire protocol — see
[docs/protocol.md](docs/protocol.md). Any device that implements the
protocol's serial or BLE side can be driven by this broker; nothing here is
tied to a specific piece of hardware. If you build (or already have) a
microcontroller project with an LED strip, an e-ink panel, or any other
status display, point it at this broker.

## Install

```bash
pip install nimbus-notify
```

This installs three commands on your `PATH`:

- `nimbus-notify-broker` — the daemon that maintains session state and talks to
  your device.
- `led-report` — the small CLI that harness hooks call to report events
  into the broker (fire-and-forget; never blocks your agent).
- `nimbus-notify` — setup helper: `install-hooks` wires `led-report` into your
  harness config (idempotent, preserves existing hooks); `install-allow-rules`
  pre-approves Claude Code's wake-up tools for unattended loops; `doctor` checks
  your setup.

> **If `pip` itself errors** before it reaches this package (e.g. a Python 3.14
> `pyexpat`/`libexpat` dylib mismatch on macOS), that's a broken host pip — use
> [`uv`](https://docs.astral.sh/uv/): `uv tool install nimbus-notify`. See
> [QUICKSTART.md](QUICKSTART.md#1-install) for the caveats.

To hack on it, install from source instead:

```bash
git clone https://github.com/ristllin/nimbus-notify.git
cd nimbus-notify
pip install -e .
```

## Quickstart

1. Install the package (above).
2. **Connect your device** to this computer — over USB or Bluetooth. See
   [Connecting your device](#connecting-your-device) below (it's one command).
3. Wire up the harness(es) you use — see [Harnesses](#harnesses) below.
4. Start the broker (if it isn't already up from step 2):
   ```bash
   nimbus-notify-broker            # or: --transport ble   /   --transport auto
   ```
5. Start (or resume) an agent session in a wired-up harness. Its status
   should now be reported to the broker, and forwarded to your device.

If you use Claude Code, the fastest path is the bundled slash commands —
see [Claude Code plugin](#claude-code-plugin) below.

## Connecting your device

The broker talks to your status device **one of two ways** — you don't need
both, pick whichever fits:

### Option A — USB cable (simplest)

Plug the device into your computer with a USB cable and run:

```bash
nimbus-notify-broker --transport serial      # or just: nimbus-notify-broker
```

The broker auto-detects the port. That's it — no pairing, no setup. Good for a
device that sits on your desk next to the machine. (To pin a specific port:
`--transport serial --port /dev/cu.usbmodem101`.)

### Option B — Bluetooth, no cable (wireless)

If the device is powered from a wall plug / battery and you **don't** want a USB
cable to your computer, use Bluetooth LE. There is **nothing to pair in System
Settings** — macOS bonds on demand the first time the broker touches the device:

1. **Power on the device** and make sure it's in **Notifier** mode and in radio
   range. (Notifier is the status-light mode; if the device is in Orchestrator
   mode, switch it from the device's knob menu or its web page.)
2. Run the broker **in the foreground**, once:
   ```bash
   nimbus-notify-broker --transport ble
   ```
3. The **first** time, macOS completes a silent "Just Works" bond (no code to
   type, no dialog). Leave the broker running for a few seconds — the device's
   ring will start reflecting your sessions once a frame arrives.

> **Why foreground the first time?** macOS only finishes the bond for a process
> in your normal login session, so don't background it (`nohup … &`) for the
> *first* connect. After the bond is stored (on both the Mac and the device's
> flash) every later run is transparent and can be backgrounded or run as a
> [service](#running-persistently). Full detail + un-bonding:
> [Bonding the BLE link](#bonding-the-ble-link-one-time).

**Which do I have?** If you flashed the firmware yourself over USB, the cable is
already there — use Option A. If the device is across the desk on its own power,
use Option B. `--transport auto` picks serial when a board is plugged in at
startup, else Bluetooth.

> **First time with a brand-new device?** Getting the device onto your Wi-Fi,
> naming it, and choosing Notifier vs Orchestrator is a separate one-time setup
> done from the **device itself** (its setup Wi-Fi + a QR code), not from this
> broker — see the Nimbus
> [First-time setup guide](https://ristllin.github.io/Nimbus/docs/getting-started/first-time-setup).
> For plain Notifier-over-Bluetooth you don't even need Wi-Fi — just Option B above.

## Harnesses

nimbus-notify supports three AI coding harnesses today. Each harness reports
events (session start, a tool running, waiting on your approval, done,
errored, session end) by calling `led-report <harness> <verb>` from a hook.

**The fastest way to wire any harness is the installer** (idempotent, keeps your
existing hooks, safe to re-run):

```bash
nimbus-notify install-hooks                       # all harnesses
nimbus-notify install-hooks --harness claude --dry-run   # preview
```

The manual per-harness steps below are the fallback (and what the installer does).

### Claude Code

`nimbus-notify install-hooks --harness claude` writes this for you. To do it by
hand, merge [`hooks/claude/settings.json`](hooks/claude/settings.json)'s `hooks`
block into your `~/.claude/settings.json`, preserving any hooks you already
have (append to each event's array rather than replacing it). It wires up
`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `Notification`, `Stop`,
`StopFailure`, and `SessionEnd`. ⚠ The "needs you" ring uses the **`Notification`**
event — not `PermissionRequest` (a Codex event name that Claude Code never emits).

#### Wake-up allow-rules (unattended loops)

If you run Claude Code **unattended** (overnight loops, scheduled wake-ups, a
headless fleet), pre-approve the wake-up tools so a session can arm and retire
its own wake-ups without stopping for a permission prompt:

```bash
nimbus-notify install-allow-rules            # merge into ~/.claude/settings.json
nimbus-notify install-allow-rules --dry-run  # preview the change first
```

This appends `ScheduleWakeup`, `CronCreate`, `CronDelete`, and `CronList` to
`permissions.allow` (idempotent, backs up first, never touches your other
permissions). **Why it matters here:** Claude Code gates those tools behind an
approval prompt by default. In an unattended session that prompt is itself the
stuck-ring bug: the session parks in `AwaitingApproval` (an amber "needs you"
segment) waiting on a human who is not watching, and the wake-up never arms.
Pre-approving them lets the loop both arm a wake-up and self-terminate cleanly,
so no segment gets pinned. Skip this if you only run Claude Code interactively;
`doctor` treats it as advisory, never a failure.

### Codex

Merge [`hooks/codex/hooks.json`](hooks/codex/hooks.json) into
`~/.codex/hooks.json`, then enable hooks and the notify bridge in
`~/.codex/config.toml`:

```toml
[features]
hooks = true

notify = ["led-report", "codex-notify"]
```

### Mistral Vibe

Vibe has no native session start/stop hook, so the broker also runs a
background watcher over the Vibe session-log root to detect sessions and
infer human-in-the-loop waits (if a tool call starts but doesn't finish
within a timeout, that's treated as "awaiting approval"). On Vibe v2.25+ it
uses the session lease (`<save_dir>/active/<id>.lock`) for a precise session
start and end.
Unified-harness sessions resolve their cwd from
`<save_dir>/unified/<id>/meta.json` and share one segment per working directory
with their hook events; while a session holds its lease the watcher heartbeats it,
so it clears when the lease is released (or its process dies), not on the idle TTL.

The simplest way to wire the hooks is the installer (idempotent; it also
upgrades a stale file in place):

```bash
nimbus-notify install-hooks --harness vibe
```

That writes the current Vibe v2.21.0+ hook names (`pre_tool` / `post_tool` /
`post_agent`) into `~/.vibe/hooks.toml`. For Vibe **older than 2.21** add
`--vibe-legacy` (it writes `before_tool` / `after_tool` / `post_agent_turn`
plus `enable_experimental_hooks = true`, which 2.21 removed). Run
`nimbus-notify doctor` afterwards; it detects the installed Vibe version and
tells you exactly what to run if the hook names don't match. The reference
file is [`hooks/vibe/hooks.toml`](hooks/vibe/hooks.toml).

### Claude Code plugin

This repo is also a Claude Code plugin (`.claude-plugin/plugin.json`). If
you install it as a plugin, two slash commands become available:

- `/nsnotify-setup` — installs the Python package, merges the Claude Code
  hooks automatically, checks whether the broker is running, and prints
  the manual steps for Codex/Vibe.
- `/nsnotify-status` — shows current session states without needing to look
  at the device.

## Transports

Pick a transport with `--transport`:

```bash
nimbus-notify-broker --transport serial   # default
nimbus-notify-broker --transport ble
nimbus-notify-broker --transport auto     # serial if a device is plugged in at
                                      # startup, else BLE
```

- **Serial** auto-detects a likely USB-CDC port (Espressif native-USB VID
  `0x303A`, or common USB-UART bridge chips), or pin one explicitly:
  `nimbus-notify-broker --transport serial --port /dev/cu.usbmodem101`.
- **BLE** requires your device to be powered on, flashed with firmware that
  advertises the nsn BLE service, and in range. Optionally pin a specific
  device: `nimbus-notify-broker --transport ble --ble-address <address>`
  (a CoreBluetooth UUID on macOS, a MAC address on Linux). Without
  `--ble-address`, the broker scans for the nsn service UUID.

Transport selection happens once at startup — there's no live failover
between serial and BLE mid-session in this version.

### Session eviction (`--ttl`)

A session that ends **cleanly** (`/exit`, Ctrl-D) frees its ring segment
instantly via the `SessionEnd` hook. A session that is **hard-killed** (closing
the terminal, `kill`, force-quit) never runs that hook, so the broker reaps it
after an idle timeout instead:

```bash
nimbus-notify-broker --ttl 120   # default: drop a silent benign session after 120 s
```

- `--ttl SECONDS` sets the window for **benign** states (idle / running / done).
  Lower it for a snappier ring; raise it to keep quiet-but-alive sessions on the
  ring longer. Floored at 5 s.
- **Call-to-action** states (awaiting approval / awaiting input / error) always
  hold **300 s** (matching the device's 5-minute attention hold) regardless of
  `--ttl`, so a job that's blocked *on you* can't quietly disappear from the ring
  while it's still pending. A session whose reported `pid` was seen alive and is
  now gone is evicted on the very next sweep, so a killed job never waits out the
  full hold.

For **unattended loops**, two more signals keep a segment from getting pinned
(both resolve to existing states, so no firmware change is needed):

- **`wakeup`**: when a session arms a scheduled wake-up (Claude Code
  `ScheduleWakeup` / `CronCreate`, reported via a `PostToolUse` hook that
  `install-hooks` wires for you), the turn has handed off to a timer, not to you.
  The broker resolves that window to a benign **Done** that ages out on the short
  TTL, so a Stop-less wake-up window never leaves a lit arc pinned, and the 60-second
  idle notification during a wake-up wait is not mistaken for a "needs you" prompt.
- **`heartbeat`**: a liveness ping (`led-report claude heartbeat --pid $PPID`) that
  refreshes a session's idle timer without changing what the ring shows. A long
  supervised turn can send it to stay off the reaper; it never relights a finished
  or pending segment.

See [docs/protocol.md](docs/protocol.md#host-event-protocol-led-report---broker)
for the full host event vocabulary.

### Bonding the BLE link (one time)

Recent Nimbus firmware **secures the BLE link** (bonded + encrypted, LE Secure
Connections), so a device won't accept frames from an un-bonded computer — this
stops anyone in radio range from painting your ring. Bonding is **automatic**
(macOS "Just Works", no code to type) — but there are two things to know:

- **Nimbus does *not* appear in System Settings → Bluetooth.** That list only
  shows recognized device types (keyboards, mice, audio). A custom BLE
  peripheral is invisible there by design — don't look for it. Bonding happens
  on-demand when the broker first touches the device, not by picking it in a
  list.
- **Do the first bond with the broker in the foreground.** macOS only completes
  a bond for a process running in your normal login session — a fully detached
  process (e.g. `nohup … & disown`) is too detached and the bond silently fails.
  So the *first* time, just run `nimbus-notify-broker --transport ble` in a normal
  terminal and leave it up for a few seconds. Once bonded, the bond persists on
  both the device (flash) and your Mac, and every session after that is
  transparent — you can then run it backgrounded or as a service (below).

To un-bond: on the device, *Connectivity → Forget paired devices* (or the
`FORGETBONDS` console command); the Mac side clears on its own next connect.

> The firmware also carries a dormant MITM/passkey mode (it shows a 6-digit code
> on its e-ink screen). It's off by default because macOS won't surface the
> passkey-entry dialog for a broker-triggered pairing of a custom peripheral, so
> that pairing can't complete without a companion app.

## Running persistently

For day-to-day use you'll want the broker running in the background
whenever you're coding, not started by hand each time — it's a long-lived
listener the session hooks fire into, so if it isn't running your device just
stops updating.

### Recommended: `--install-service`

One command installs (and later removes) the auto-start service — a macOS
launchd LaunchAgent or a Linux systemd user unit — that starts the broker at
every login/reboot:

```bash
nimbus-notify-broker --install-service     # enable auto-start
nimbus-notify-broker --uninstall-service   # remove it
```

**⚠ macOS + BLE:** run `nimbus-notify-broker --transport ble` in the foreground
**once** first to complete the "Just Works" bond (a fully-detached process can't
— see [Bonding the BLE link](#bonding-the-ble-link-one-time)); serial needs no
bond. The service uses
`--transport auto` (serial if a board is plugged at boot, else BLE).

### Quick one-off (no service)

To background it for just this session without installing anything — detach it
from the terminal, log to a file, and then close the window:

```bash
nohup nimbus-notify-broker --transport auto >/tmp/nimbus-notify-broker.log 2>&1 & disown
```

Same BLE caveat: complete the one-time foreground bond first (a `nohup … &
disown` process is too detached to finish the initial pairing — see
[Bonding the BLE link](#bonding-the-ble-link-one-time)). Verify with
`pgrep -f nimbus-notify-broker` or `nimbus-notify doctor`.

The rest of this section documents what `--install-service` writes, if you'd
rather manage it by hand.

### macOS (launchd)

`--install-service` writes `~/Library/LaunchAgents/com.nimbus-notify.broker.plist`
(equivalent to creating it yourself):

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.nimbus-notify.broker</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/bin/env</string>
    <string>nimbus-notify-broker</string>
    <string>--transport</string>
    <string>auto</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>/tmp/nimbus-notify-broker.log</string>
  <key>StandardErrorPath</key><string>/tmp/nimbus-notify-broker.log</string>
</dict>
</plist>
```

Then:

```bash
launchctl load ~/Library/LaunchAgents/com.nimbus-notify.broker.plist
```

### Linux (systemd, user service)

Create `~/.config/systemd/user/nimbus-notify-broker.service`:

```ini
[Unit]
Description=nimbus-notify broker

[Service]
ExecStart=%h/.local/bin/nimbus-notify-broker --transport auto
Restart=on-failure

[Install]
WantedBy=default.target
```

Then:

```bash
systemctl --user daemon-reload
systemctl --user enable --now nimbus-notify-broker
```

Adjust `ExecStart` to wherever `pip install -e .` put the entry point
(check with `which nimbus-notify-broker`).

## The nsn wire protocol

A short summary — full details in [docs/protocol.md](docs/protocol.md).

```
[SOF 0xAA] [LEN] [payload: LEN bytes] [CRC8-MAXIM]
```

The payload starts with magic byte `0x4E`, then a sequence number, then up
to 16 fixed-size segment records (state, hue, animation, LED span) plus a
global brightness — enough for a device to render a full status frame from
a single self-contained packet, no persistent client-side state required.
The reference encoder/decoder is [`notify/broker/frame.py`](notify/broker/frame.py).

## Repository layout

```
notify/
  broker/       frame.py (wire codec), segments.py, server.py, session.py
  cli/          led_report.py — the hook-facing CLI entry point
  harness/      base.py + claude.py, codex.py, vibe.py adapters
  transport/    serial_tx.py, ble_tx.py
  state.py      shared State/Anim enums + default styling
tests/          pytest suite for the above
hooks/          drop-in hook configs per harness
commands/       Claude Code plugin slash commands
docs/           protocol.md
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md), including the versioning
convention used for releases.

## License

MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Roy Darnell.
