Make a Moonside Halo lamp show local Codex status using Codex lifecycle hooks and BLE.
Codex hook event
-> codex-moonside-hook
-> /tmp/codex_moonside_state.json
-> codex-moonside-daemon
-> Moonside lamp over local BLE
The hook exits quickly and never talks to BLE. The daemon owns the persistent BLE connection and watches the state file.
Version 0.2.0 (alpha), tested locally.
Tested setup:
- macOS
- Python 3.12
- Codex local app/CLI hooks with
hooks = true - Moonside Halo advertising as
MOONSIDE-O101
Linux may work if bleak can access the Bluetooth adapter. Windows is not a target.
| Codex event | Lamp state | Default visual |
|---|---|---|
SessionStart |
idle |
warm solid color |
UserPromptSubmit |
working |
animated BEAT2 |
PermissionRequest |
attention |
purple gradient approval-needed signal |
matching PostToolUse after approval |
working |
return to working |
ordinary PostToolUse |
working |
quiet activity heartbeat |
Stop |
tool_done |
green finish flash, then idle |
SessionEnd |
idle |
release session ownership |
Tool-use events are intentionally not used for separate tool-running transitions. Ordinary PostToolUse events still refresh working so a premature Stop event or long-running turn does not leave the lamp idle while Codex is active.
When multiple Codex chats are open, the hook uses an active-session lock:
- the latest prompt claims the lamp
- other sessions cannot force idle while that session is active
- permission state cannot be overwritten by another session
- a new prompt can claim the lamp immediately
SessionEndor lock expiry releases abandoned ownership
Codex currently does not expose a distinct “user clicked approve” hook. This project treats the matching PostToolUse after a permission request as the approval-resolved signal.
Night mode can turn the lamp off during a local quiet window while still allowing Codex activity to wake it back up. It only applies when the daemon believes the lamp is idle or ambient; working, attention, and other active states continue to show normally.
{
"night_mode_enabled": true,
"night_mode_off_time": "23:00",
"night_mode_on_time": "07:00",
"night_mode_state": "off"
}When the clock is inside the configured window, idle or ambient states are replaced with night_mode_state. A new Codex prompt, permission request, or tool heartbeat still applies its normal state and turns the lamp back on. At the end of the quiet window, the daemon restores the idle or ambient state that night mode replaced.
git clone https://github.com/ulanenko/codex-moonside-lamp.git
cd codex-moonside-lamp
scripts/setup-local
source .venv/bin/activateCreate the default config:
python install_hooks.py --write-configThis writes ~/.codex-moonside-lamp/config.json if missing.
Scan nearby BLE devices:
codex-moonside-daemon --scanLikely Moonside devices are marked with *. On macOS, BLE addresses are usually UUID-like identifiers rather than stable MAC addresses.
If multiple Moonside lamps are nearby, copy the address from --scan into:
{
"ble_address": "YOUR-SCANNED-ADDRESS"
}Test the lamp:
codex-moonside-daemon --test-state attentionRaw command testing is useful for tuning effects:
codex-moonside-daemon --raw-command LEDON --raw-command BRIGH070 --raw-command 'THEME.GRADIENT1.180,0,255,40,0,120'Enable Codex hooks in ~/.codex/config.toml:
[features]
hooks = trueOlder Codex builds used codex_hooks = true; current desktop builds warn that this name is deprecated.
Generate hook JSON using the absolute path to your local hook executable:
python install_hooks.py --print-hooksInstall global Codex hooks if you do not already have a custom ~/.codex/hooks.json.
--trust-hooks also writes the hooks.state trust hashes required by newer Codex builds:
python install_hooks.py --write-hooks --trust-hooksUse --force only if you want to overwrite the existing hooks file:
python install_hooks.py --write-hooks --trust-hooks --forceIf you edit ~/.codex/hooks.json later, rerun:
python install_hooks.py --trust-hooksCodex only runs hook command configs whose current trusted hash is recorded in
~/.codex/config.toml.
Restart Codex after changing hook configuration.
Run the setup doctor when hooks do not fire, after a Codex update, or before filing an issue:
codex-moonside-doctorIt checks:
- Codex binary and version lookup
~/.codex/config.tomlhook feature flags~/.codex/hooks.jsonexpected events- newer Codex
hooks.statetrusted hashes - hook executable paths
- a safe direct hook smoke test using a temporary state file
- macOS LaunchAgent status
- recent daemon and hook logs
If hook trust hashes are missing or stale, apply the safe fix:
codex-moonside-doctor --fixThis updates only the hook trust entries in ~/.codex/config.toml.
For the strongest check, run a real tiny Codex CLI turn:
codex-moonside-doctor --live-codex-testThat can spend a small number of tokens and briefly change the lamp, so it is opt-in.
Foreground daemon:
codex-moonside-daemonLogs:
~/.codex-moonside-lamp/daemon.log
~/.codex-moonside-lamp/hook.log
Install a user LaunchAgent so the daemon starts when you log in:
scripts/install-macos-serviceThe installer copies runtime code to ~/.codex-moonside-lamp/runtime because macOS may block LaunchAgents from executing code inside protected folders such as Documents.
Check service status:
launchctl print gui/$(id -u)/local.codex-moonside-lamp
tail -f ~/.codex-moonside-lamp/daemon.logUninstall the LaunchAgent:
scripts/uninstall-macos-serviceIf daemon code changes after installing the LaunchAgent, rerun:
scripts/install-macos-serviceThe macOS menu bar app is a local wrapper around the Python daemon. It does not talk to BLE directly. It reads the existing config, state, and log files, then calls the same local scripts and CLI commands you would run from Terminal.
Build, install, launch, and start the local unsigned app at login:
scripts/install-macos-menu-appRemove it with:
scripts/uninstall-macos-menu-appThe menu shows daemon status, current lamp state, configured lamp target, and quick actions for testing attention, testing ambient, turning the lamp off, scanning BLE devices, restarting the daemon, and opening logs/config.
Current limitations:
- macOS only
- local unsigned app
- expects the project checkout and
.venvto remain available - generated
.appbundles are not committed to Git
Default config path:
~/.codex-moonside-lamp/config.json
Important fields:
{
"state_file": "/tmp/codex_moonside_state.json",
"permission_file": "/tmp/codex_moonside_permission_pending.json",
"session_lock_file": "/tmp/codex_moonside_active_session.json",
"session_lock_enabled": true,
"session_lock_max_age_seconds": 43200,
"permission_marker_max_age_seconds": 43200,
"lamp_name_contains": "Moonside",
"ble_address": null,
"poll_interval_seconds": 0.2,
"reconnect_interval_seconds": 3,
"command_delay_seconds": 0.25,
"write_response": true,
"skip_redundant_commands": true,
"minimum_attention_seconds": 1.0,
"ambient_after_idle_seconds": 1800,
"ambient_state": "ambient",
"codex_process_tracking_enabled": false,
"codex_process_names": ["ChatGPT", "Codex"],
"codex_process_poll_interval_seconds": 15,
"codex_process_timeout_seconds": 5,
"codex_process_warning_interval_seconds": 300,
"codex_process_missing_seconds": 60,
"codex_process_missing_state": "ambient",
"codex_process_return_state": "idle",
"log_max_bytes": 2097152,
"log_backup_count": 3
}Optional macOS process tracking lets the daemon switch the lamp to ambient, or to any other configured state, when the Codex desktop app has been closed for a while:
{
"codex_process_tracking_enabled": true,
"codex_process_missing_seconds": 60,
"codex_process_missing_state": "ambient",
"codex_process_return_state": "idle"
}This watches the current ChatGPT.app name and the legacy Codex.app name with a lightweight exact pgrep check, so it does not require the menu bar app. Process checks run outside the BLE event loop and warnings are rate-limited. Leave tracking disabled if you mainly use Codex CLI without the desktop app.
Default attention effect:
{
"attention": {
"commands": ["LEDON", "BRIGH070", "THEME.GRADIENT1.180,0,255,40,0,120"]
}
}Default ambient effect after 30 minutes idle:
{
"ambient": {
"commands": ["LEDON", "BRIGH070", "THEME.RAINBOW3.0"]
}
}Moonside command strings are reverse-engineered and empirical. If a theme does not work, switch that state to simple commands such as LEDON, BRIGH060, and COLOR255120040.
This project is local-only:
- no Moonside cloud API
- no external server
- no prompt text in state files
- no command output in state files
- no file contents in state files
The hook stores only minimal state metadata such as event name, session id, tool name, working directory, and timestamp.
If scanning prints Bluetooth is not available to Bleak, verify:
- Bluetooth is enabled
- the terminal/app running Python has Bluetooth permission in macOS Privacy & Security
- you are running on the host macOS session, not a remote/containerized shell
If the LaunchAgent is loaded but the lamp does not connect, check:
tail -f ~/.codex-moonside-lamp/launchd.stderr.log
tail -f ~/.codex-moonside-lamp/daemon.logOnly one daemon should own the lamp BLE connection. Stop any foreground codex-moonside-daemon before relying on the LaunchAgent.
python3 -B -m unittest discover -s tests
python3 -B -m py_compile codex_moonside/*.py install_hooks.py
codex-moonside-doctor --skip-direct-hook-testbobek-balinek/claude-lamp: Claude Code status integration for Moonside lamps.- TheGreyDiamond Moonside reverse-engineering notes and BLE connector examples.
- Codex hooks documentation and the
hooksfeature flag.
MIT

