Skip to main content

Physical status light for coding agents (Claude Code / Codex) via busylight

Project description

VibeSignal

A daemon-free physical status light for AI coding agents (Claude Code, Codex)

PyPI License: BSD-2-Clause Python Status: alpha GitHub Stars

Install · macOS Quickstart · How It Works · Three Renderers · Configure Agents · What's Next


vibesignal floating widget: a calm grey panel while agents work, turning red the moment a session blocks for your input

[!TIP] Maintained by Yue Zhao: USC CS faculty, author of PyOD (9.8K stars, 38M+ downloads, ~12K research citations).

[!NOTE] When Claude Code or Codex needs your reply, a USB light on your desk turns amber. When an agent finishes its turn, it turns blue. While an agent is working, green. A light on the desk is harder to miss than another system notification, which is the whole point.

What You Get

  • 🟢 Solid colors, daemon-free: the state persists in the hardware after the hook exits; no service to keep alive
  • 🪝 Hook-driven: UserPromptSubmit, PostToolUse, Notification, Stop, SessionEnd all wire in via JSON
  • 🤖 Cross-agent: the same store covers Claude Code and Codex; one light tracks both
  • 📺 Three renderers: USB busylight (hardware), terminal watch panel, always-on-top Tk widget
  • 🚦 Multi-session aware: runs 4–5 agents in parallel; the widget shows which one is blocked
  • 🍎 macOS one-click: install-launcher + install-autostart wire the .app and the LaunchAgent
  • 🪟 Cross-platform: Windows, macOS, Linux; per-platform fonts and work-area detection

Install

pip install vibesignal

On macOS, add the macos extra for accurate Dock-aware widget placement:

pip install 'vibesignal[macos]'

Then wire autostart for your OS.

macOS (one-click launcher in Spotlight and the Dock, plus login autostart):

vibesignal install-launcher
vibesignal install-autostart

Windows: put a shortcut to pythonw -m vibesignal widget in the Startup folder (Win+R, type shell:startup, drop the shortcut there).

Linux: add a .desktop entry under ~/.config/autostart/ that runs vibesignal widget.

Install the latest unreleased build from GitHub
pip install 'vibesignal @ git+https://github.com/yzhao062/vibesignal.git'

# with the macOS extra:
pip install 'vibesignal[macos] @ git+https://github.com/yzhao062/vibesignal.git'

After install, configure your agents to fire the hooks.

macOS Quickstart

First-run walk-through on a Mac:

# 1. Install (with macos extra for accurate Dock-aware placement)
pip install 'vibesignal[macos]'

# 2. Wire Claude Code hooks (one-time, user level)
#    Merge hooks/claude-settings.snippet.json into ~/.claude/settings.json
#    under "hooks". See `Configure Agents` below for details.

# 3. Install the one-click .app launcher
vibesignal install-launcher
#    Spotlight: Cmd+Space, type 'VibeSignal', press Enter.
#    Or drag ~/Applications/VibeSignal.app to the Dock.

# 4. Install login autostart (also starts the widget right now)
vibesignal install-autostart

# 5. Verify
vibesignal status            # active sessions and the resolved color
launchctl print gui/$UID/io.github.yzhao062.vibesignal | grep -E "state|program"

After step 4 a small panel appears in the bottom-left of your screen. When any Claude Code session blocks for permission, the panel turns red and shows which session.

Daily lifecycle on macOS:

Action Command
Show widget on demand Cmd+Space → VibeSignal → Enter (Spotlight); or vibesignal widget &
Quit widget window Right-click or Control-click header → Quit
Force-kill a running widget pkill -f "vibesignal.widget"
Start the LaunchAgent right now (no relogin) launchctl kickstart gui/$UID/io.github.yzhao062.vibesignal
Disable autostart, keep launcher vibesignal uninstall-autostart
Re-enable autostart later vibesignal install-autostart (idempotent)
Remove the .app launcher vibesignal uninstall-launcher
Inspect autostart status launchctl print gui/$UID/io.github.yzhao062.vibesignal
Tail autostart logs tail /tmp/io.github.yzhao062.vibesignal.log /tmp/io.github.yzhao062.vibesignal.err
Re-pin paths after switching conda env vibesignal install-autostart
Manually clear stuck sessions vibesignal clear (all) or vibesignal clear --session <id>

How It Works

Every hook invocation does the full cycle and exits. Nothing has to stay running.

%%{init: {'theme': 'base', 'themeVariables': {
  'primaryColor': '#dbeafe', 'primaryTextColor': '#1e3a8a',
  'primaryBorderColor': '#3b82f6', 'lineColor': '#475569',
  'fontFamily': 'system-ui', 'fontSize': '13px'
}}}%%
flowchart LR
    H["Claude Code / Codex hook"] --> C["vibesignal event<br/>--agent X --state Y"]
    C --> S[("~/.vibesignal/<br/>sessions/*.json")]
    S --> L["USB busylight"]
    S --> P["Watch panel"]
    S --> W["Floating widget"]

Each hook fires vibesignal event, which writes one JSON file per (agent, session), reads every active file, resolves the aggregate state by priority (blocked > error > done > working > idle), and updates the USB light if the color changed. Sessions that stop emitting events drop off after their per-state TTL.

Concurrent hooks stay honest two ways. The record-resolve-apply cycle holds a short cross-process lock, so two sessions firing at once cannot leave the light on a lower-priority color (a finishing working hook overwriting a fresh blocked event from another session). Every state file is written atomically (tempfile plus os.replace), so a reader never sees a half-written file. The lock is bounded: if it cannot be taken quickly it gives up and proceeds, because never blocking the agent's hook matters more than perfect ordering under rare contention.

State Table

State Light Set by (Claude Code hook) Meaning
blocked Amber, solid Notification (permission_prompt) An agent needs you now
done Blue, solid Stop, StopFailure, Notification (idle_prompt) An agent finished its turn; your move
working Green, solid UserPromptSubmit, PostToolUse An agent is busy, do not interrupt
error Red, solid Manual only A failure
idle Off TTL timeout, done-fade Nothing needs you

Aggregate priority across active sessions: blocked > error > done > working > idle. If any one agent is waiting on you, the light is amber, so the signal you care about most is never hidden.

Three Renderers

Renderer Command Where It Lives
🟢 USB busylight Driven automatically by every event call Physical light on the desk
📋 Watch panel vibesignal watch Live multi-session TUI in a terminal pane
🪟 Floating widget vibesignal widget Always-on-top Tk window

All three read the same state store, so they stay in sync. The widget shows which session is blocked when several agents run at once. The USB light shows the highest-priority state across all sessions.

Watch Panel

vibesignal watch

Live table, one row per active session, blocked rows first:

  PROJECT          AGENT    STATE      FOR
* aegis            claude   * blocked  1m12s
* agent-audit      codex    * blocked  8s
o iet-paper        claude   o done     3s
. random           claude   . working  --

Foreground viewer (Ctrl-C to stop), not a daemon. vibesignal watch --once renders a single snapshot.

Floating Widget

Cross-platform Tk panel, always-on-top, draggable by the header. Right-click to quit; on macOS, Control-click also opens the Quit menu.

# macOS: open via Spotlight ("VibeSignal") after install-launcher, or:
vibesignal widget &

# Windows (no console window):
pythonw -m vibesignal widget

# Linux:
vibesignal widget &

The widget pins to the bottom-left of the work area on first launch, then becomes draggable. A done row fades after about 90 seconds, a silent working row clears after 10 minutes, and blocked or error rows persist until the state changes or the 8-hour backstop expires.

Configure Agents

Claude Code

Merge hooks/claude-settings.snippet.json into ~/.claude/settings.json under "hooks". The keys (UserPromptSubmit, PostToolUse, Notification, Stop, StopFailure, SessionEnd) do not collide with any default hooks. Notification is split by matcher: permission_prompt sets blocked, idle_prompt sets done. SessionEnd clears the session at once instead of waiting out the TTL.

The vibesignal command reads the session id from the hook's stdin JSON, so one light tracks every concurrent session.

[!TIP] PostToolUse returns the light to green after you approve a mid-task permission prompt, at the cost of one quick call per tool use. Drop it if you prefer zero per-tool overhead; the light then stays amber until the turn ends.

Codex

The state store is agent-agnostic: events carry an --agent tag. Codex points at the same command with --agent codex, so one light covers both. See hooks/codex-hooks.md for the mapping (Codex's notify program or 0.130+ hooks system).

Test Without Hardware

The light arrives later than the code does, so the whole pipeline is observable without a device:

vibesignal event --agent claude --state working
vibesignal status        # active sessions and the resolved color
vibesignal off           # clear all sessions

With no light connected, event records state and prints the color it would set, then exits cleanly. Hooks never fail when the light is missing or unplugged.

Hardware

There is no purpose-built "AI agent light" product. The proven path is a commercial presence light plus the open-source busylight-core library, which supports many USB lights across multiple vendors.

Light Form Notes
Luxafor Flag 2 Magnet on a monitor edge, USB-C Eye-level spot, holds its color
blink(1) mk2 Tiny, fully open Long-standing developer favorite

Both are on Amazon US and supported by busylight-core. Check the live price before buying.

Why Solid Colors, Not Blinking

Blinking a USB light needs a process that stays alive to drive the blink, which would mean a daemon. Solid colors persist in the light hardware after the process exits, so they fit the daemon-free design. Amber solid is still very visible.

This assumes a light that holds its last state (Luxafor, blink(1), BlinkStick). Kuando-style lights that need a constant connection would require the daemon mode even for solid colors.

Per-State Lifetimes
  • done fades after ~90 seconds (a transient "your move" pulse)
  • working clears after 10 minutes of silence (a silent working session is treated as dead)
  • blocked and error persist for up to 8 hours: nothing refreshes them while they wait on you, and a shorter TTL would drop a long-pending prompt exactly when it is most overdue. Cleared sooner when you act on it, when the session ends, or by vibesignal clear.

The 8-hour backstop only self-cleans a hard-crashed session that left no final event.

macOS Launcher and Autostart Internals

Two helper subcommands wire up macOS-native paths without any new package dependency:

# One-click launcher: compiles an AppleScript .app via `osacompile` into
# ~/Applications/VibeSignal.app. Spotlight-able, draggable to the Dock.
vibesignal install-launcher
vibesignal uninstall-launcher

# Login autostart: writes ~/Library/LaunchAgents/io.github.yzhao062.vibesignal.plist
# with the absolute path of `vibesignal` baked in (so LaunchAgent's empty PATH
# is not an issue), then loads via `launchctl bootstrap gui/<uid>`. The widget
# starts immediately (RunAtLoad=true) AND at every future login. Re-run after
# switching env to re-pin.
vibesignal install-autostart
vibesignal uninstall-autostart

The work area is detected per platform: SPI_GETWORKAREA on Windows (taskbar excluded); NSScreen.visibleFrame on macOS (menu bar and Dock excluded), with a 28 / 80 px heuristic fallback when pyobjc is absent; full screen on Linux. The fallback assumes a bottom Dock; install the macos extra for accurate placement under any Dock orientation.

Fonts: Segoe UI on Windows, Helvetica Neue on macOS, DejaVu Sans elsewhere.

Project Layout
vibesignal/
|-- README.md
|-- DESIGN.md
|-- LICENSE
|-- pyproject.toml
|-- vibesignal/
|   |-- __init__.py
|   |-- store.py        # per-session state files + TTL + atomic writes + last-color cache
|   |-- resolve.py      # aggregate + per-session resolution -> colors
|   |-- light.py        # busylight wrapper, no-ops without a device
|   |-- lock.py         # bounded cross-process lock for the event critical section
|   |-- panel.py        # live multi-session TUI panel (foreground viewer)
|   |-- widget.py       # always-on-top floating GUI panel (Tkinter, stdlib)
|   |-- installer.py    # macOS one-click .app + LaunchAgent autostart helpers
|   |-- __main__.py     # CLI invoked by hooks
|-- hooks/
|   |-- claude-settings.snippet.json
|   |-- codex-hooks.md
|-- tests/
    |-- test_resolve.py
    |-- test_store.py
    |-- test_lock.py
    |-- test_main.py
    |-- test_panel.py
    |-- test_widget.py
    |-- test_installer.py
    |-- test_hooks.py

What's Next

[!NOTE] Shipped since the last release: PyPI packaging (pip install vibesignal) and a GitHub Actions CI matrix (Windows / macOS / Linux, Python 3.11 to 3.13).

  • Homebrew tap at yzhao062/homebrew-tap for brew install yzhao062/tap/vibesignal
  • Multi-LED strip support: a BlinkStick Strip with one cell per session (the store already keys by session; needs a session -> cell map in resolve.py)
  • Daemon mode (opt-in) for blinking patterns and auto-off after idle, at the cost of a service to keep alive

License

BSD 2-Clause © 2026 Yue Zhao

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vibesignal-0.1.0.tar.gz (39.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vibesignal-0.1.0-py3-none-any.whl (27.5 kB view details)

Uploaded Python 3

File details

Details for the file vibesignal-0.1.0.tar.gz.

File metadata

  • Download URL: vibesignal-0.1.0.tar.gz
  • Upload date:
  • Size: 39.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vibesignal-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4c1f7f6c6b3977b095fed41c424fdc88f4f581db3f5d00c2927c02b317a72b06
MD5 19f31b7df8d31d8f57806313dbcf4431
BLAKE2b-256 863340d912fb2bf6711ff894bd9c50edc0193659051000d5e47f31a9131597ce

See more details on using hashes here.

Provenance

The following attestation bundles were made for vibesignal-0.1.0.tar.gz:

Publisher: publish.yml on yzhao062/vibesignal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file vibesignal-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: vibesignal-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 27.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for vibesignal-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e616003f20952bed3adf5bdab3f7832888485a6d4b1d36f692190e1a1c2fb272
MD5 3aefeae8fdbfa705221495555f66f205
BLAKE2b-256 f206e2889b9fcea7eec69d870813971d8f7cc226346b3b6e6492eb3c6a0e104b

See more details on using hashes here.

Provenance

The following attestation bundles were made for vibesignal-0.1.0-py3-none-any.whl:

Publisher: publish.yml on yzhao062/vibesignal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page