macOS launchd agent that wires the Mac's screen-lock state and Claude Code's token usage to two physical outputs:
-
A TP-Link Kasa smart plug / power strip via the
python-kasaCLI. Named outlets switch OFF on screen lock and ON on screen unlock. Typical setup: one outlet for a desk lamp, one for a 9V supply feeding an Arduino motor rig (the outlet doubles as a hardware kill switch for the motor). -
An Arduino + DC motor speaking a small serial protocol at 115200 baud. Claude Code token activity drives motor RPM via an exponentially-decaying activity level. Bursts spin the motor up, idle periods coast it down.
The Arduino sketch lives in a companion repo:
terminalbytes/arduino-motor-control-pwm.
Full build writeup, photos, and the demo video: A motor that runs at the speed of my Claude Code usage on terminalbytes.com.
- Mac unlocked, Claude idle → motor stopped, lamp on.
- Mac unlocked, agent burning tokens → motor spins, faster with sustained activity.
- Activity tails off → motor decelerates with active reverse braking on the flywheel.
- Screen locked → lamp off, motor off, Kasa outlets cut so the motor is electrically dead.
Requires macOS (uses Foundation.NSDistributedNotificationCenter for screen-lock
events).
git clone https://github.com/terminalbytes/screen-watcher.git
cd screen-watcher
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cp .env.example .env
# edit .env: set KASA_HOST, KASA_OUTLET_NAMES, MOTOR_PORTFind your Kasa device's IP from your router's DHCP table or:
venv/bin/kasa discoverFind your Arduino's serial port after plugging it in:
ls /dev/cu.usbmodem*set -a && source .env && set +a
venv/bin/python screen_watcher.pyLock and unlock the Mac to see events; if a Kasa device is configured, the
named outlets should toggle. If an Arduino is on MOTOR_PORT and running the
motor-control-pwm sketch,
it should respond to SPEED= writes.
com.terminalbytes.screen-watcher.plist.sample is a working template. Copy it
to ~/Library/LaunchAgents/, replace <USERNAME> and <REPO_PATH> with real
values, then:
launchctl load -w ~/Library/LaunchAgents/com.terminalbytes.screen-watcher.plistLogs go to screen_watcher.log and screen_watcher.err.log in the repo dir.
| Env var | Default | What |
|---|---|---|
KASA_HOST |
192.168.1.100 |
Static LAN IP of the Kasa device |
KASA_OUTLET_NAMES |
Standing Lamp,Fidget |
Comma-separated outlet names on the device |
KASA_CLI |
./venv/bin/kasa |
Path to the python-kasa CLI |
MOTOR_PORT |
/dev/cu.usbmodem8401 |
Arduino serial device |
MOTOR_BAUD |
115200 |
Serial baud rate (match the sketch) |
POLL_SECONDS |
1.0 |
Seconds between activity polls |
ACTIVITY_HALF_LIFE_SECONDS |
30.0 |
Half-life of the activity-level decay |
ACTIVITY_FLOOR |
5000 |
Below this activity level, motor is off |
CLAUDE_PROJECTS_DIR |
~/.claude/projects |
Where Claude Code writes JSONL transcripts |
Set MOTOR_PORT to an invalid value (e.g. MOTOR_PORT=disabled) and the
motor serial open silently fails; the Kasa side keeps working.
Set KASA_OUTLET_NAMES= (empty) and run_kasa no-ops.
Each poll, the agent:
- Decays the running activity level
Lby0.5 ** (dt / half_life). - Reads any newly-appended bytes from every JSONL under
CLAUDE_PROJECTS_DIR, sums theusage.{input,output,cache_*}_tokensfields, adds toL. - Looks up
Lin a piecewise threshold table to derive a motor SPEED 0..10.
The thresholds in screen_watcher.py are tuned for the author's workflow.
Expect to retune them: heavy users want larger thresholds, lighter users
smaller. Run the daemon with tail -f screen_watcher.log for a few hours,
watch the L= values that correspond to your normal usage, and adjust.
MIT. See LICENSE.