Metadata-Version: 2.4
Name: sidepulse
Version: 0.0.0
Summary: Command-line and macOS tools for SidePulse Pro and SidePulse Dot.
Author-email: Peter Kuhar <peter@pkuhar.com>
License-Expression: MIT
Project-URL: Homepage, https://sidepulse.io
Project-URL: Repository, https://github.com/inteliwear/sidepulse
Project-URL: Issues, https://github.com/inteliwear/sidepulse/issues
Keywords: sidepulse,led,macbook,agent,status,cli
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Hardware
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: qrcode<9,>=8
Requires-Dist: pyobjc-framework-Cocoa>=10; sys_platform == "darwin"
Requires-Dist: pyobjc-framework-Quartz>=10; sys_platform == "darwin"
Requires-Dist: pyobjc-framework-WebKit>=10; sys_platform == "darwin"
Requires-Dist: pyobjc-framework-ScriptingBridge>=10; sys_platform == "darwin"
Provides-Extra: status-bar
Provides-Extra: reply-classifier
Requires-Dist: mlx-lm<1,>=0.24; extra == "reply-classifier"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: tomli>=2; python_version < "3.11" and extra == "test"
Dynamic: license-file

# sidepulse

`sidepulse` is the command-line and macOS companion project for
[SidePulse](https://sidepulse.io).

They can display the status of an AI agent, battery level, or other system
signals.

| <img src="https://raw.githubusercontent.com/inteliwear/sidepulse/main/media/sidepulse-pro.jpg" alt="SidePulse Pro glowing pink in a MacBook Pro SD card slot" width="400"> | <img src="https://raw.githubusercontent.com/inteliwear/sidepulse/main/media/sidepulse-dot.jpg" alt="SidePulse Dot glowing green in a MacBook USB-C port" width="400"> |
|:---:|:---:|
| **SidePulse Pro** — eight-LED SD card device for MacBook Pro. | **SidePulse Dot** — tiny two-LED USB-C device. |

Agent status, at a glance:

https://github.com/user-attachments/assets/9de119ac-7b55-467f-8517-6c5f1570c1af

The device mounts as a disk drive. You can control the LEDs by writing to `LEDS.LED`.

The LED control DSL is described in [`LEDS_FORMAT.md`](LEDS_FORMAT.md).

## Installation

Choose the level that fits how you want to use SidePulse.

### 1. One-command setup

```sh
curl -fsSL https://sidepulse.io/setup.sh | bash
```

The command works on macOS and Linux with Python 3.10 or newer. The
[setup script](scripts/setup.sh) creates an isolated environment under
`~/.local/share/sidepulse/venv`, installs SidePulse from GitHub, links the CLI
at `~/.local/bin/sidepulse`, and runs `sidepulse setup`. On macOS, setup also
installs the menu-bar app and hardware helpers. On Linux, it automatically
uses CLI-only mode for mounted SidePulse devices and iPhone linking/push. Run
`sidepulse update` to upgrade to the newest version from GitHub.

Setup also installs a headless SidePulse service. It runs through a LaunchAgent
on macOS and a user systemd service on Linux. Setup restarts the service, so an
upgrade takes effect right away instead of leaving the previous build running.
If a Linux session has no systemd user manager, setup still installs the unit
and prints that it could not start; `sidepulse service run` remains available as
a foreground fallback.

### 2. Install into your own Python environment

If you already manage your own Python 3.10+ environment:

```sh
python3 -m pip install --upgrade \
  "git+https://github.com/inteliwear/sidepulse.git"
sidepulse setup
```

### 3. Clone it for development

Use an editable installation when you want to modify or hack on SidePulse:

```sh
git clone https://github.com/inteliwear/sidepulse.git
cd sidepulse
python3 -m pip install -e .
sidepulse setup
```

### Updating

```sh
sidepulse update
sidepulse update --dry-run
```

`update` runs the same installer as `curl -fsSL https://sidepulse.io/setup.sh | bash`.
It downloads the script completely before executing it, so curl failures do not
run a partial installer. The website script forwards to `scripts/setup.sh` on
GitHub, keeping both entry points on one implementation.

The installer updates `~/.local/share/sidepulse/venv` from GitHub, refreshes the
CLI links, and runs `sidepulse setup` to configure hooks and restart services.
It replaces the installed code even when the package version has not changed.
Settings and linked devices are retained. Downloads and builds finish before
the background service and menu-bar app are paused; if installation fails,
the installer attempts to restart them. Restart manually started processes yourself.

Both commands honor `PYTHON_BIN`, `SIDEPULSE_INSTALL_SPEC`,
`SIDEPULSE_INSTALL_ROOT`, and `SIDEPULSE_BIN_DIR`. They use the managed install
path above, even when invoked from a development checkout or bundled app.

Send an LED program to SidePulse:

```sh
sidepulse write "off\n#ff3a00 1.6s pulse\nrepeat"
```

`sidepulse write` prefers mounted hardware and falls back to a linked phone.
`sidepulse push` accepts the same options but prefers a linked phone, which is
useful for hooks and remote notifications:

```sh
sidepulse push "off\n#00ff66 pulse\nrepeat" \
  --title "Build complete" \
  --message "All tests passed"
```

The LED program is optional when a title or message is present:

```sh
sidepulse push --title "Agent needs input" --message "Choose a deployment region"
```

Choose a destination by its displayed name or short ID. If multiple compatible
destinations are available, SidePulse asks for `--to` instead of silently
broadcasting. Use `--all` when broadcasting is intentional:

```sh
sidepulse write "off" --to "Peter's iPhone"
sidepulse push "off" --to a1b2c3d4
sidepulse write "off" --all
```

Both commands accept `-` for stdin. `sidepulse write` also reads piped stdin when
no LED argument or notification text was supplied:

```sh
printf 'off\n#ff3a00 pulse\nrepeat' | sidepulse write
sidepulse push - --title "Deploying" < deploy.LED
```

The CLI auto-detects mounted devices by looking for a SidePulse Pro/SidePulse
Dot-style volume name or an existing `LEDS.LED`. It checks `/Volumes` on macOS
and common removable-media locations such as `/media/$USER`,
`/run/media/$USER`, and `/mnt` on Linux. For a specific local path, the
existing `--device` option remains available:

```sh
sidepulse write "off\n#ff3a00 1.6s pulse\nrepeat" --device /Volumes/SidePulsePro
sidepulse write "off" --device /Volumes/SidePulsePro/LEDS.LED
# Linux example:
sidepulse write "off" --device /media/$USER/SidePulseDot
```

The writer decodes simple escapes such as `\n`, then enforces the controller's
512-byte and 20-line limits before writing the LED control file.

For an **unmounted macOS device**, the standalone standard-library utility can
write the existing `LEDS.LED` directly through `/dev/diskN` or `/dev/rdiskN`:

```sh
# Replace disk4 with the SidePulse device. Unmount first if already mounted.
diskutil unmountDisk /dev/disk4
sudo python3 scripts/write_led_raw.py /dev/rdisk4 'off\n#ff00ff 1s pulse\nrepeat'
sudo python3 scripts/write_led_raw.py /dev/disk4 - < animation.LED
```

It requires a whole drive of **at most 300,000 bytes**, verified using device
capacity queries on the open disk before writing. Partitions, unknown capacity,
mounted volumes, and larger drives are rejected, with no override. The filesystem
must be FAT12 starting at sector zero, with one FAT and 512-byte sectors. Programs
are limited to 512 bytes and 20 lines. It writes the existing file's first data
sector and updates its directory size, preserving its FAT allocation.

### Link an iPhone

Run one command and either scan the terminal QR code with the SidePulse iOS app
or paste the push token shown by the app. Production tokens have 64 hexadecimal
characters; development tokens have the `dev_` prefix followed by 64 hexadecimal
characters. Keep the prefix when pasting or saving a development token.

```sh
sidepulse link
```

List saved iPhones and remove a stale link by its displayed ID:

```sh
sidepulse unlink
sidepulse unlink ID_FROM_LIST
sidepulse link
```

`unlink` requires an exact ID. This keeps phones with the same name separate.
If two links have the same displayed ID, remove the intended entry by its full
token from `~/.config/sidepulse/agent-monitor/links.json` (or the equivalent
under `$XDG_CONFIG_HOME/sidepulse/agent-monitor/links.json`). Pair with the
updated iOS build by scanning the QR code or pasting its current push token.
Do not add or remove `dev_` by hand; the token determines the bridge route.

Linked phones are stored locally. Remote LED-only writes are silent. Adding
`--title` or `--message` produces one visible notification containing the same
LED program and event metadata. Remote payloads also include the sending
computer's name and available battery state. Set `SIDEPULSE_SERVER` to use
another bridge origin.

### Link a remote VM or computer (WIP)

The receiving Mac gets a persistent, random relay channel during setup. Run
`sidepulse link` on the Mac to see the short command for another computer, then
paste that command into the VM. It looks like this:

```sh
sidepulse link AbCdEfGhIjKlMnOpQrStUv
```

The VM stores that channel locally. Its agent hooks hand events to the
background SidePulse service, which publishes them to the Mac. The Mac records
the remote events alongside its local agent history and updates its connected
SidePulse hardware or linked iPhone. The VM is an event source, so it does not
appear in the Devices menu.

The channel is a 128-bit capability token: possession grants access, so treat
the command like a password. There is no separate one-time code. Check the
service on either computer with:

```sh
sidepulse service status
```

## Battery LEDs

Show the current Mac battery state:

```sh
sidepulse battery status
sidepulse battery status --json
```

Mirror battery level to a mounted SidePulse Pro/SidePulse Dot:

```sh
sidepulse battery leds
sidepulse battery leds --once --dry-run
sidepulse battery leds --device /Volumes/SidePulsePro --full-watts 140
```

SidePulse Pro uses all eight LEDs as a battery bar. At 50%, LEDs 0-3 are filled;
when charging, LED 4 is the pulsing frontier LED. Live updates ease the whole
strip into its new base state, then the device animation engine repeats the
frontier pulse. Pulse length and the pause between pulses are based on charger
wattage divided by the laptop's full-speed wattage baseline, so slow chargers
produce occasional short blinks and full-speed chargers produce a steady pulse.
The app only rewrites the program when the battery level, charging state,
charger power, brightness, or selected device changes.
Power-source changes arrive through the macOS IOKit notification run loop, so
plug/unplug previews and charging-state changes do not wait for the general
status refresh. A low-frequency poll remains as a fallback.

Save the status-bar LED display preference:

```sh
sidepulse battery configure --display battery
sidepulse battery configure --display agent
sidepulse battery configure --full-watts auto
sidepulse battery configure --show-on-power-change yes --power-change-preview-seconds 7
```

## macOS companion app

`sidepulse` includes a companion menu-bar app for macOS that controls
SidePulse Pro and SidePulse Dot.

### Main Functionality

#### AI Agent Monitoring

SidePulse can monitor AI agents such as Codex, Claude, Grok, Cursor, and Junie through hooks, then
translate the current agent state into a small, glanceable LED status.

Agent status modes:

| Mode | Meaning | LED pattern |
| --- | --- | --- |
| Idle / Ready | The agent is available and not currently running a task. | Very dim idle pulse. |
| Working | The agent is thinking, generating, or otherwise actively processing. | Cyan rolling animation. |
| Tool Running | A shell command, API call, or external tool is in progress. | Cyan rolling animation. |
| Waiting for Input | The agent needs a user decision, approval, or additional context. | Slow amber pulse. |
| Long Task Progress | A longer job has measurable progress. | Cyan rolling animation. |
| Blocked / Error | The agent cannot continue, a tool failed, or a recoverable error needs attention. | Slow amber pulse. |
| Completed | The agent finished successfully. | Solid green. |

Each mode can be configured independently in **Settings... → Animations**.
Every state uses the same animation library: **Idle Pulse**, **Cyan Roll**,
**Amber Pulse**, **Solid Green**, **KITT Scanner**, **KITT Scanner Red**, and
the **Ember** and **Purple** animation families, plus any named custom animation.
**Add Custom…** adds a reusable `LEDS.LED` program to that shared
library, and animation profiles save or apply all state selections together.
The built-in profiles are **Cyan** (the default), **Ember**, and **Purple**.
Built-ins live in `src/sidepulse/resources/animations/` as `.LED` files; patterns
that depend on the hardware layout have only `-2.LED` and `-8.LED` variants.
Custom programs are stored in the `animations/` folder beside `settings.json`.
An animation may set its own `brightness`, which is multiplied by the device's
brightness setting so the device setting remains the overall limit. The tab shows a
live SidePulse Notch-rendered preview for every state;
**Show** sends that pattern to connected agent-display devices for three
seconds, then restores live status. **Current** appears whenever individual
state selections no longer match one of the built-in or saved profiles.
Profiles can be exported as self-contained JSON—including referenced custom
animations—and imported on another SidePulse installation.

When multiple states are active, SidePulse should show the most actionable
mode first: Blocked / Error, Waiting for Input, Tool Running, Long Task
Progress, Working, then Idle / Ready.

For multiple agents, SidePulse aggregates their statuses into one global
display state. Each agent reports its own mode, and SidePulse renders the
highest-priority active mode across all non-stale agents. This keeps the device
useful at a glance: if any agent is blocked or waiting, the LEDs show that
actionable state instead of trying to show every agent separately.

Aggregation priority:

| Priority | Mode | Aggregated behavior |
| --- | --- | --- |
| 1 | Blocked / Error | Show immediately if any agent is blocked or has errored. |
| 2 | Waiting for Input | Show if any agent needs user input and no agent is blocked. |
| 3 | Tool Running | Show if any agent is running a tool and no higher-priority state is active. |
| 4 | Long Task Progress | Show the most recent or furthest-progressing long task. |
| 5 | Working | Show while one or more agents are actively processing. |
| 6 | Completed | Show briefly when the latest active agent completes successfully. |
| 7 | Idle / Ready | Show only when all known agents are idle or no fresh agent status exists. |

Agent statuses should include a timestamp. SidePulse should ignore stale
statuses after a short timeout so disconnected or finished agents do not hold
the display indefinitely.

#### Agent Monitor Library

The `sidepulse` Python package collects and normalizes local AI agent hook
events. The macOS status-bar app receives hook events through a lightweight
local Unix socket, keeps the latest agent states in memory, and writes only a
small `latest.json` restart snapshot plus provider JSONL debug logs. Hooks also
append `event-status.jsonl`, a compact decision log that records each hook event
and the SidePulse status it produced for debugging/export. The app does not
rescan historical logs or transcripts on every refresh.

The package can also mirror the aggregate state to a mounted SidePulse Pro or
SidePulse Dot by writing the current LED program to `LEDS.LED`.

The monitor currently supports:

| Provider | Config | Detected log |
| --- | --- | --- |
| Codex | `~/.codex/config.toml` | `${XDG_STATE_HOME:-~/.local/state}/sidepulse/agent-monitor/codex.jsonl` |
| Claude | `~/.claude/settings.json` | `${XDG_STATE_HOME:-~/.local/state}/sidepulse/agent-monitor/claude.jsonl` |
| Grok | `~/.grok/hooks/sidepulse.json` | `${XDG_STATE_HOME:-~/.local/state}/sidepulse/agent-monitor/grok.jsonl` |

#### Local reply classifier (Apple Silicon)

Install the optional MLX dependency, then classify a message with the default
4-bit `mlx-community/Qwen2.5-0.5B-Instruct-4bit` model:

```sh
python3 -m pip install -e '.[reply-classifier]'
sidepulse-reply "Could you check this for me?"
echo "Thanks, I received it." | sidepulse-reply --json
```

The model runs locally and uses deterministic greedy decoding. To benchmark the
canonical labeled examples plus recent assistant messages collected in the
SidePulse decision log:

```sh
python3 examples/benchmark_reply_classifier.py --warm-runs 20 --log-examples 12
```

Generate the labeled dataset (eight human-collected examples plus 300 balanced,
reproducible synthetic examples):

```sh
python3 scripts/generate_reply_dataset.py
```

The resulting `data/reply_expectation.jsonl` records `label`, `source`, `split`,
and `category`. Human-collected examples are kept in the test split and synthetic
examples are explicitly marked so evaluation can report them separately.

For CLI snapshots, debugging, or recovery after missed hook events, the
file-based monitor can optionally read recent local transcripts as a fallback:

- Codex: `~/.codex/sessions/**/*.jsonl`
- Claude: `~/.claude/projects/**/*.jsonl`

Transcript monitoring is off by default and can be enabled in Settings. It can
catch active threads even when hook events are stale or missed. Claude
transcript files can be touched after their embedded event timestamps stop
moving, so a recent transcript mtime is treated as a Working heartbeat only
when the latest embedded event was already active. File mtimes never resurrect
a terminal `Stop` / `Completed` session. Internal Codex helper/suggestion
transcripts are ignored so app background work does not look like one of your
agents.

By default the monitor stores runtime logs under
`~/.local/state/sidepulse/agent-monitor/`, following the XDG state directory
convention. Set `XDG_STATE_HOME` to place them somewhere else.

Install locally for the `sidepulse` CLI:

```sh
python3 -m pip install -e .
```

For an isolated user installation that does not modify system Python packages:

```sh
./scripts/install-user.sh
~/.local/bin/sidepulse setup
```

The installer creates `~/.local/share/sidepulse/venv` and links the CLI into
`~/.local/bin`. Override `PYTHON_BIN`, `SIDEPULSE_INSTALL_ROOT`, or
`SIDEPULSE_BIN_DIR` when a different location is needed.

This also installs the Cocoa dependencies for the macOS status-bar app.

Set up this Mac explicitly after package install:

```sh
sidepulse setup
```

`sidepulse setup` installs or refreshes all supported agent hooks, including
Junie CLI, installs SidePulse Pro Eject Prevention, writes the status-bar
LaunchAgent, starts both helpers immediately, and enables them at login. This is
intentionally an explicit command instead of a `pip install` side effect. To set
up only one provider, pass its name, for example `sidepulse setup junie`.
To skip the status-bar app but still install hooks and SidePulse Pro Eject Prevention, use
`sidepulse setup --no-status-bar`.

SidePulse Pro Eject Prevention keeps the built-in SD reader attached after
macOS hibernate or lock-screen mount refusals. By default setup installs it
system-wide when already running with system permissions, otherwise as a
per-user LaunchAgent:

```sh
sidepulse setup --sd-eject-guard-scope auto
sidepulse setup --sd-eject-guard-scope user
sidepulse setup --sd-eject-guard-scope system --no-status-bar
```

The system scope requires the command to already have system install
permissions.

Manage SidePulse Pro Eject Prevention directly:

```sh
sidepulse sdejectguard start
sidepulse sdejectguard stop
sidepulse sdejectguard uninstall
sidepulse sdejectguard logs
sidepulse sdejectguard start -it
```

`start -it` runs the guard in the current terminal for interactive debugging.

On Homebrew Python, use the user-site install form:

```sh
python3 -m pip install --user --break-system-packages -e .
ln -sf "$(python3 -m site --user-base)/bin/sidepulse" ~/.local/bin/sidepulse
```

### macOS installer

A signed and notarized PKG release can be built with
[`packaging/build_macos_pkg.sh`](packaging/build_macos_pkg.sh). See
[`packaging/README.md`](packaging/README.md) for the required Developer ID
certificates and notarization profile.

Check the current hook configuration:

```sh
sidepulse agent-monitor doctor
```

Install or refresh the monitor hooks:

```sh
sidepulse agent-monitor install
sidepulse agent-monitor install codex
sidepulse agent-monitor install claude
sidepulse agent-monitor install grok
sidepulse agent-monitor install cursor
sidepulse agent-monitor install junie
```

Each hook invokes a small, standard-library-only Python entry point. It writes
the event to the monitor log and then makes a short best-effort local socket
delivery to the status-bar app.

Show current aggregated status:

```sh
sidepulse agent-monitor status
```

Watch a live dashboard of recently active agents:

```sh
sidepulse agent-monitor live
```

The dashboard refreshes every second and shows agents updated in the last hour
by default. Use `--recent-seconds` to change that window, or `--all` to
include stale/older sessions:

```sh
sidepulse agent-monitor live --recent-seconds 120
sidepulse agent-monitor live --all
```

By default, `Tool Running` events are not time-limited, so genuinely long tools
remain visible. If a provider drops completion hooks and you want protection
against stale tool starts, set `--tool-running-timeout`.

Codex's `Interrupt` hook clears Working, Tool Running, and Ask when a turn is
interrupted. The session returns to Idle / Ready without being marked completed;
the next prompt makes it active again. Reinstall the Codex hooks after upgrading
to register this event. Transcript monitoring is not required.

`PostToolUse` means the tool returned, not that the whole turn is finished. The
monitor keeps it as Working for a short settling window while the assistant
writes the response, then treats it as Done if no newer hook event arrives. This
prevents a missed final `Stop` event from leaving the status bar stuck on
Working.

`Completed` remains visible for 20 minutes so the status bar and LEDs can show
Done long enough to be noticed. After that it drops out instead of counting as
an active session for the full stale window, and the LEDs return to the very
dim Idle pattern. Idle/session-start records also do not count as active
sessions.

Status detection is strongest when the agent tells the monitor its intended
handoff state explicitly. A final assistant message can include a hidden marker
line:

```text
<!-- sidepulse:ask -->
<!-- sidepulse:done -->
<!-- sidepulse:working -->
<!-- sidepulse:blocked -->
<!-- sidepulse:idle -->
```

Explicit markers win over text heuristics. If no marker is present, the monitor
falls back to provider events and then to conservative question detection in the
final assistant message. Casual closing questions such as "Anything else?" are
treated as Done unless the agent emits `<!-- sidepulse:ask -->`; concrete
follow-ups such as "Want me to push?" still count as Ask. Questions inside
markdown code spans or fenced code examples are ignored.

Codex `PermissionRequest` events are treated as Ask and remain sticky until the
matching tool command finishes. This prevents unrelated same-session activity
from hiding an approval prompt that is still waiting on the user.

For Codex, Claude, Grok, Cursor, or Junie projects that should report this reliably, add
guidance like this to the relevant agent instructions:

```text
When your final response needs user input, approval, or a decision, include
`<!-- sidepulse:ask -->` as a final hidden marker line. When the work is complete
and no user response is needed, include `<!-- sidepulse:done -->`.
```

Mirror the aggregate agent status to the LEDs in a foreground process:

```sh
sidepulse agent-monitor leds
```

The LED mirror writes only when the aggregate display state changes. Use
`--once` to write the current state and exit, or `--dry-run` to inspect the LED
program:

```sh
sidepulse agent-monitor leds --once --dry-run
sidepulse agent-monitor leds --device /Volumes/SidePulseDot
```

SidePulse Dot programs are generated for two LEDs. SidePulse Pro programs are generated
for eight LEDs. The monitor detects this from the mounted device name and falls
back to the eight-LED SidePulse Pro layout if the name is unknown.

Remove monitor hooks:

```sh
sidepulse agent-monitor uninstall
sidepulse agent-monitor uninstall codex
sidepulse agent-monitor uninstall claude
sidepulse agent-monitor uninstall grok
sidepulse agent-monitor uninstall cursor
sidepulse agent-monitor uninstall junie
```

Install and start the macOS status-bar app:

```sh
sidepulse status-bar
sidepulse status-bar start
```

This writes `~/Library/LaunchAgents/io.sidepulse.agentstatus.plist`, starts the
menu-bar app immediately, enables it at login, and mirrors the same aggregate
state to the LEDs. For debugging, run it in the foreground:

```sh
sidepulse status-bar start --foreground
```

On first launch, the status-bar app shows a SidePulse Setup window. It can:

- enable Run at Login;
- install or uninstall SidePulse Pro Eject Prevention, which keeps SidePulse Pro/SidePulse Dot available after sleep;
- open the one-time closed-lid sleep prevention installer in Terminal.

The Setup window can be reopened from the dropdown with `Setup...`.

The status-bar item uses a single icon, with the current state in its tooltip:

| Label | Meaning |
| --- | --- |
| Idle | No recent active agent work. |
| Working | One or more agents are thinking, running tools, or progressing. |
| Done | The most recent active agent completed successfully. |
| Ask | An agent needs input, permission, or attention. |

To hide the icon, open **Settings → Advanced** and uncheck **Show menu bar icon**.
Monitoring and LEDs continue running. Reopen settings with `sidepulse settings`
in Terminal (it starts the app if needed), or open the installed SidePulse app
from Finder or Spotlight. Check **Show menu bar icon** to restore it.
Running `sidepulse status-bar` (or `sidepulse status-bar start`) also restores
the icon immediately. Automatic starts at login retain your visibility preference.

Click the status-bar item to expand the recent session list. Click a session
row to open that agent using the remembered choice for that provider. Use the
session's Open Options row to choose and remember another opener, such as the
provider app, Terminal resume, or Claude Code in VS Code.

The dropdown also includes a checked `Connect to Device` item. A checkmark means
the status-bar app is actively connected to a mounted SidePulse Pro/SidePulse Dot target.
If both devices are mounted, the status-bar app prefers SidePulse Pro, then
SidePulse Dot. Click the item to disconnect and turn the LEDs off; click it again to
reconnect.

The menu-bar dropdown can switch the LEDs between agent status and battery
status. In **Settings → Advanced**, `Show battery for 7s on plug/unplug` can
briefly show the battery animation for seven seconds after the power source
changes when agent status is selected.

The Devices section also offers **Add SidePulse Notch**, an optional virtual
eight-LED device. It appears as a notch-shaped status-bar overlay that covers
the camera island/notch footprint and adds a straight 5 px LED band along the
bottom edge, or the corresponding top-center position on a display without a
notch. Each virtual LED blends across a three-LED footprint: centered on the
target LED, fading one LED width left and right. It shares the physical
device's status animations, display-mode selection, and per-device brightness
control. SidePulse Notch evaluates the same `LEDS.LED` programs with the
firmware/websim `sdled.wasm` engine, then AppKit only draws the returned RGB
frames.

Open `Settings...` from the dropdown to manage agent integrations. The settings
window can install or uninstall Codex, Claude, Grok, and Junie hooks. The transcript
checkboxes control the file-based CLI/debug fallback; the status-bar app gets
live updates from the local hook event socket. Settings are stored at
`${XDG_CONFIG_HOME:-~/.config}/sidepulse/agent-monitor/settings.json`.

Junie support uses its user-level `~/.junie/config.json` hooks. Junie currently
emits hooks from its interactive and batch CLI hosts, including CLI sessions
connected to a JetBrains IDE; Junie hosted directly through IDE/ACP does not yet
emit hooks. SidePulse intentionally does not register a Junie `PermissionRequest`
hook because a successful observer hook would automatically approve the requested
action. Junie's normal approval prompts therefore remain unchanged.

Settings can export the hook decision log as CSV or HTML. This log lives at
`${XDG_STATE_HOME:-~/.local/state}/sidepulse/agent-monitor/event-status.jsonl`
and shows the path from provider hook event to interpreted SidePulse status.

The `Keep Awake With Lid Closed` menu section controls the stronger sleep
prevention policy:

The intended closed-lid behavior, including the distinction between macOS
clamshell mode and SidePulse's no-external-display fallback, is documented in
[`docs/design/closed-lid-power.md`](docs/design/closed-lid-power.md).

| Choice | Behavior |
| --- | --- |
| Never | Do not use the closed-lid sleep override. |
| When Agents Work | Keep the Mac awake while agents are Working / Tool Running / Progressing, plus the existing five-minute Ask / Done / Error grace period. |
| Always | Keep the closed-lid sleep override active while the status-bar app is running. |

The status-bar app still keeps the SidePulse Pro/SidePulse Dot volume active by touching
a `keepalive` file on each connected device at least once per minute. The
closed-lid policy uses the SidePulse sleep helper when it is installed. The PKG
installer sets this up automatically; source/dev installs can run the one-time
setup command:

```sh
sudo "$(command -v sidepulse)" status-bar install-sleep-helper
```

The helper is a narrow sudoers rule for exactly
`/usr/bin/pmset -a disablesleep 0|1`, so the status-bar app can toggle it
silently with non-interactive `sudo`. SidePulse uses this automatically for
`Keep Awake With Lid Closed` and only restores the setting if SidePulse changed
it. Remove the helper with:

```sh
sudo "$(command -v sidepulse)" status-bar uninstall-sleep-helper
```

Open **Settings... → Animations** to choose and preview the Lid Closed and Lid
Open rows alongside the agent-state animations. Animation programs use the same `LEDS.LED` syntax as
`sidepulse write`; device brightness is applied automatically before writing.

The app is also installed as a user LaunchAgent at
`~/Library/LaunchAgents/io.sidepulse.agentstatus.plist`.

Stop and remove the LaunchAgent:

```sh
sidepulse status-bar stop
```

Use it from another Python app:

```python
from sidepulse import AgentMonitor, LiveAgentMonitor

snapshot = AgentMonitor.from_default_sources().snapshot()
print(snapshot.aggregate.mode.value)
for status in snapshot.statuses:
    print(status.provider, status.mode.value, status.cwd)

live = LiveAgentMonitor()
```

Publish a hook-shaped event to the status-bar app from another local process:

```python
from sidepulse import send_hook_event

send_hook_event(
    "codex",
    {
        "logged_at": "2026-07-13T12:00:00Z",
        "event": {
            "hook_event_name": "Stop",
            "session_id": "example",
            "last_assistant_message": "Done.",
        },
    },
)
```

#### Audio Monitor Example

`examples/audio_monitor.py` turns microphone volume into a smooth LED level
bar. The LEDs stay dim at rest, run green through yellow to red, and brighten as
the audio level fills the bar.

Install the optional live-audio dependencies:

```sh
python3 -m pip install sounddevice numpy
```

Preview the meter in the terminal without touching a device:

```sh
python3 examples/audio_monitor.py --dry-run --terminal
```

Write to a mounted SidePulse Pro or SidePulse Dot:

```sh
python3 examples/audio_monitor.py --device /Volumes/SidePulsePro --terminal
python3 examples/audio_monitor.py --device /Volumes/SidePulseDot --terminal
```

List audio inputs or tune sensitivity:

```sh
python3 examples/audio_monitor.py --list-inputs
python3 examples/audio_monitor.py --device /Volumes/SidePulsePro --gain-db 8 --release 0.45
```

#### Battery Monitor

...

#### 

## Tests

```sh
python3 -m pip install -e '.[test]'
python3 -m pytest tests -q
```

CI runs the suite on macOS and Linux, and a tagged release will not publish
unless it passes. Four files, each guarding a different failure mode:

| File | Guards against |
| --- | --- |
| `tests/test_packaging.py` | Importing a module no dependency declares. Every module-level import must resolve to a declared distribution, so a new `import` without a matching `pyproject.toml` entry fails at commit time rather than on a user's machine. |
| `tests/test_environment.py` | A working dev machine hiding a broken install. Builds an empty virtualenv, runs `pip install .` against `pyproject.toml` alone, then imports every module and runs the real commands — including `sidepulse status-bar start --foreground` — inside it. An undeclared dependency is simply absent there. |
| `tests/test_hook_stability.py` | Breaking somebody's agent session. Hooks must exit 0 and stay silent on stdout for every input and internal failure — and must still work with PyObjC entirely unavailable. |
| `tests/test_status_bar_ui.py` | Menus and windows that build but crash when clicked. Runs headlessly against a real `StatusBarController`; verifies every selector string resolves to a real method. |
| `tests/test_sidepulse.py` | Collector, settings, install, and provider logic. |

The UI tests skip if AppKit is unavailable. Set `SIDEPULSE_REQUIRE_UI_TESTS=1`
to turn that skip into a failure, which is how CI runs them.

The clean-room install tests take ~15s because they build a virtualenv and
install into it. Set `SIDEPULSE_SKIP_CLEAN_INSTALL=1` to skip them while
iterating; CI always runs them.

### Releasing

Build and verify the wheel and source archive locally:

```sh
./scripts/release.sh
```

The script generates a sortable calendar version such as `1.20260901.67530`:
major version `1`, UTC date `20260901`, and seconds since UTC midnight `67530`.
It runs the test suite in an isolated environment, builds the wheel and source
archive, checks their package metadata, installs the wheel into a clean
environment for a smoke test, and writes SHA-256 checksums beside the artifacts
in `dist/`. It prints the exact tag to push, but never tags, publishes, or
pushes itself. Use `--skip-tests` only when the suite has already passed in the
same checkout.

Pushing the printed tag, for example
`git tag v1.20260901.67530 && git push origin v1.20260901.67530`, runs the full
suite, uses the tag as the package version, builds, publishes, and then
reinstalls the release **from PyPI** on a clean macOS runner to re-run the
clean-room tests against the artifact users actually download.

The version comes from the tag, so no source files need a version bump. To
point the clean-room tests at any published build:

```sh
SIDEPULSE_INSTALL_SPEC='sidepulse==1.20260901.67530' python3 -m pytest tests/test_environment.py -v
```
