A daemon that mirrors the state of your AI coding agent sessions onto a physical keyboard — one key/LED slot per active session, color-coded by what that session is doing (working, waiting on you, done, errored, etc.) — pressing a key brings that session's window (Terminal, VS Code, or IntelliJ) to the front. The hardware, wire protocol, and daemon core are agent-agnostic; see Agents tested below for which coding agents are actually wired up today.
Any RGB QMK keyboard works over USB HID — proven on a NuPhy Air75 V2. Porting to another QMK board should take little more than adding its own keymap (VID/PID plus per-key mapping) — the daemon, HID wire protocol, and dispatch logic are already keyboard-agnostic; see QMK keyboard (NuPhy Air75 V2) for the pattern to follow.
A second QMK board, the Keychron K1 Pro (ANSI), is also wired up, built against Keychron's own official firmware source — but unverified on real hardware, and needs one small source patch applied before it'll build; see QMK keyboard (Keychron K1 Pro, unverified) before relying on it.
This repo covers the full path end to end: per-agent hook wiring (an
example settings file plus the adapter script it invokes — one pair
per agent, under claude/ and codex/), a host-side daemon that
speaks a small binary protocol with the pad over USB HID, and the QMK
keymap C that runs on the pad itself.
The daemon only holds the pad's HID connection open while at least one agent session is active, releasing it shortly after the last one ends — see Run the daemon — so the VIA app, which needs exclusive access to that same interface, can be used without manually stopping the daemon first.
The pad's hardware, HID protocol, and daemon core don't assume any
particular agent — but wiring one up still means writing the piece
that turns its own event stream into hook payloads on
~/.ai-agent-macropad/daemon.sock (see Protocol below).
Two agents have that piece written so far:
| Agent | Status | Notes |
|---|---|---|
| Claude Code | ✅ Tested | Wired via Claude Code's own hooks — see claude/example_hook_settings.json and claude/hook.sh |
| Codex CLI | ✅ Tested | Wired via Codex's own hooks (near-identical event vocabulary to Claude Code's) — see codex/example_hooks.json and codex/hook.sh. Verified end to end with a real codex exec run; see Wire up Codex hooks for the two gaps versus Claude Code (no PostToolUseFailure or Notification equivalent) |
Update this table as support for other agents is added.
Each slot's RGB color reflects that session's current state, per
STATE_TO_CODE in hid_protocol.py:
| Label | Color | Internal state | When | |
|---|---|---|---|---|
| ⚪ | idle | dim gray #282828 |
idle |
SessionStart — slot allocated, nothing happening yet |
| 🔵 | thinking | blue #0000FF |
working |
The agent is reasoning between tool calls (UserPromptSubmit, PostToolUse) |
| 🟣 | tool running | purple #8000FF |
tool_running |
A tool call is actively executing (PreToolUse) |
| 🟢 | complete | green #00FF00 |
done |
Stop — the agent finished responding |
| 🟠 | needs input | orange #FF7F00, blinking |
question |
Blocked on you: AskUserQuestion, ExitPlanMode, PermissionRequest, or Notification:agent_needs_input |
| 🟣 | tool stalled | purple #8000FF, blinking |
tool_stalled |
A tool call has been pending past STALL_THRESHOLD_SECONDS with no PostToolUse — may or may not be blocked |
| 🟡 | waiting | amber #FFAA00 |
waiting |
Claude Code's been idle 60s+ with nothing blocking (Notification:idle_prompt) — lower urgency than "needs input"; Claude-Code-only, see Agents tested |
| 🔴 | error | red #FF0000 |
error |
PostToolUseFailure — Claude-Code-only; Codex has no equivalent, see Agents tested |
"needs input" and "tool stalled" blink (0.5s on/off) so each reads as distinct from its solid-color sibling at a glance — "needs input" from "waiting" despite sharing a similarly warm color, and "tool stalled" from "tool running" despite sharing the same purple hue.
A slot that receives a state it doesn't recognize — e.g. older
firmware talking to a newer daemon that's added a state since it was
last flashed — renders solid magenta #FF00FF instead of silently
falling back to idle or off, which would look like nothing's wrong.
This is a fallback rendering behavior, not a state hook_to_state
ever produces on purpose.
Only tested on macOS. Window-dispatch (tmux/Terminal.app/VS Code/IntelliJ activation) uses AppleScript and is macOS-only outright; the rest (daemon, hook.sh, HID protocol) may work elsewhere but hasn't been tried.
| Path | Description |
|---|---|
daemon.py |
Host-side daemon: Unix socket server + hook-event → pad-state mapping + idle-release orchestration |
pad_link.py |
Owns the HID connection to the pad: discovery, open/close, read/write, reconnection |
hid_protocol.py |
Wire-level binary report format for the HID transport (QMK-based pads) — see Protocol |
fake_hooks.py |
Simulates an agent session's hook events (Claude Code by default, --agent codex for Codex's own event shape), for testing the daemon without real hooks wired up |
hid_bringup_test.py |
Standalone hello/RGB round-trip check against a real QMK pad, independent of daemon.py |
qmk-userspace/ |
QMK userspace overlay, built against a separate local QMK checkout — users/ai_agent_macropad/ holds the protocol/state logic shared by every board's keymap; keyboards/.../keymaps/ai_agent_macropad/ holds each board's own layout, LED map, and device ID. Keychron K1 Pro also ships keyboards/keychron/k1_pro/k1_pro.c.patch, a small patch applied to that board's own (unmodified-otherwise) firmware checkout — see QMK keyboard (Keychron K1 Pro, unverified) for why |
requirements.txt |
Python dependencies for the daemon |
requirements-dev.txt |
Adds pytest on top of requirements.txt, for running the test suite |
tests/ |
pytest suite for daemon.py, pad_link.py, and hid_protocol.py (see Testing) |
claude/example_hook_settings.json |
hooks block to merge into Claude Code's settings.json, wiring every relevant event to hook.sh |
claude/hook.sh |
Reads a Claude Code hook payload from stdin, enriches it, tags it agent: claude-code, and forwards it to the daemon's socket |
codex/example_hooks.json |
hooks block for Codex CLI's hooks.json/config.toml, wiring every relevant event to hook.sh |
codex/hook.sh |
Same idea as claude/hook.sh, for Codex's own (near-identical) hook payload shape — tags it agent: codex |
Claude Code hooks --> claude/hook.sh --\
>- daemon.py <-- USB HID --> QMK keyboard
Codex hooks --> codex/hook.sh --/ (Unix socket)
daemon.py listens on a Unix domain socket at ~/.ai-agent-macropad/daemon.sock
for line-delimited JSON hook payloads from any agent's adapter script,
maps each one to a display state for the originating session, and
pushes that state to the pad over USB HID. It also reads events back
from the pad (key presses) and dispatches them.
Any pad needs a USB-C cable that carries data, not just power. A lot of USB-C cables are charge-only, and the HID link needs one that actually supports data transfer.
No separate switches/keycaps shopping list here — a prebuilt keyboard
with per-key RGB is the whole requirement. This repo's keymap is
proven on the NuPhy Air75 V2;
any other QMK board with per-key RGB matrix support (RGB_MATRIX_ENABLE)
should work with a keymap of its own — see QMK keyboard (NuPhy Air75
V2) for the pattern to follow when
porting to a different board. A Keychron K1
Pro
(ANSI) keymap is also included, but unverified — see QMK keyboard
(Keychron K1 Pro, unverified).
Follow whichever subsection matches your hardware; the rest of Setup (steps 2-4 below) is shared.
Verified against real hardware. 4 slots wired by default (PageUp/PageDn/Home/End), each
showing one AI agent session's state via per-key RGB, and pressing one brings that
session's window to the front (dispatch_bring_to_front in daemon.py). On boards
built with VIA_ENABLE (this one is), up to 8 slots are reachable from the VIA app — drag
one of the "AI Slot 4".."AI Slot 7" custom keycodes (see via.json in the keymap directory)
onto any spare key in the VIA app and it lights up
automatically; remap a slot key away and its LED goes dark just as automatically. (The shared
firmware actually supports up to 12 slots, but VIA's app hard-caps customKeycodes at 32
total entries, and NuPhy's own stock entries already use most of that budget — see the
comment above enum ai_agent_macropad_keycodes in keymap.c for the exact accounting.) The
keymap source lives in this repo under
qmk-userspace/ (a QMK userspace
overlay), built against a separate local QMK
checkout that isn't part of this repo:
git clone --branch nuphy-keyboards https://github.com/nuphy-src/qmk_firmware.git ../nuphy-qmk-firmware
cd ../nuphy-qmk-firmware && git submodule update --init --recursive
brew install qmk/qmk/qmk # plus arm-none-eabi-gcc@8 (osx-cross/arm tap) for this board's STM32F072
qmk config user.qmk_home=../nuphy-qmk-firmware
cd ../ai-agent-macropad/qmk-userspace
QMK_USERSPACE="$(pwd)" qmk compile -kb nuphy/air75_v2/ansi -km ai_agent_macropad
To flash: unplug the board (or just turn it off), hold Esc, plug it back in over USB-C (or turn it back on) — this is QMK bootmagic (default row/col 0,0 = Esc on this board), not anything keymap-specific, so it works for recovery too regardless of what firmware is currently on the board:
QMK_USERSPACE="$(pwd)" qmk flash -kb nuphy/air75_v2/ansi -km ai_agent_macropad
Then verify the wire protocol works before trusting the full daemon to it —
hid_bringup_test.py pings the board directly (bypassing daemon.py
entirely) and cycles every slot the board reports through every state so you can watch the
real LEDs:
python3 hid_bringup_test.py
The VIA app and daemon.py can't hold the pad open at the same time. Both talk to the
same raw HID interface (our protocol deliberately shares VIA's endpoint rather than using a
separate one), and macOS enforces exclusive access to it at the OS level — whichever one opens
it first locks the other out, and VIA will report the keyboard as "not responding like a
VIA-enabled keyboard" if it loses that race. The daemon only holds the interface open while at
least one agent session is active (see Run the daemon), releasing it
a few seconds after the last one ends — so in practice this just means: open VIA while no
session is running, or wait a few seconds after your last session ends. If VIA still reports
the keyboard as unresponsive, the daemon likely has an active session and hasn't released the
handle yet; stop it manually (or end the session) and retry.
To reassign slots (e.g. to move a default slot off PageUp/PageDn/Home/End, or to put "AI Slot 4".."AI Slot 7" on a spare key):
- In VIA's Settings page, enable Show Design tab.
- In the new Design tab, manually load
via.json(this board isn't in VIA's official keyboard registry, so it won't be auto-detected — loading the file directly is what tells VIA how to talk to it). - Switch to the Configure tab, make sure layer 0 is selected, and drag any of the custom "AI Slot N" keycodes (bottom-left CUSTOM section) onto the key you want it to live on — or drag any other keycode onto PageUp/PageDn/Home/End to move a default slot elsewhere.
Reassignments take effect immediately — no reflashing needed. ai_agent_macropad.c's dynamic
scan picks up wherever a slot key actually is the moment you make the change (see the "Dynamic
AI-agent slots" work in this repo's history for how that works), and once you restart the
daemon it'll rediscover the board with whatever layout you left it in.
Porting to a different QMK board follows the same shape: a new keymap directory under
qmk-userspace/keyboards/, with just its layout, LED-index table, and device ID — the
HID protocol and dispatch_bring_to_front logic itself is shared code in
qmk-userspace/users/ai_agent_macropad/, not duplicated per board. Keep the keymap named
ai_agent_macropad (i.e. still -km ai_agent_macropad) so QMK's build picks up that shared
users/ai_agent_macropad/ directory automatically.
Unverified on real hardware. Unlike the Air75 keymap above, nobody has built, flashed, or
tested this one against a real board — treat everything below as a documented best-effort, not
a confirmed working path. That said, it's built against Keychron's own official firmware source
(the Keychron/qmk_firmware fork, wireless_playground
branch), not a third-party reverse-engineered one — the ANSI layout, matrix, RGB LED indices, and
VID/PID all come directly from Keychron's real keyboards/keychron/k1_pro/ansi/rgb/, and the base
keymap layers are Keychron's own stock K1 Pro keymap, unmodified except for the 4 AI-slot key
substitutions. What's unverified is specifically "does it work on a real board" — not "is this
guessed at."
One small, unavoidable wrinkle: k1_pro.c (board-level code, shared by every keymap for this
board — not something this repo's keymap directory touches) already defines via_command_kb(),
the same raw-HID early-intercept hook the Air75 keymap uses directly, to handle two vendor
commands (bluetooth DFU, factory test). A keymap can't also define via_command_kb() itself —
duplicate strong symbol, hard link error — so this board needs one small patch applied to that
file first, adding a new empty-by-default hook (raw_hid_receive_kb()) that via_command_kb()
falls through to for anything it doesn't already claim, which is where this keymap's own
raw_hid_receive_kb() (in keymap.c) plugs in. The patch is 12 lines, touches nothing any other
keymap for this board relies on, and ships in this repo as a diff. It's also been submitted
upstream as Keychron/qmk_firmware#506 — if
that gets merged, this manual step goes away for anyone building against a checkout that
includes it; worth checking before you patch by hand.
git clone --branch wireless_playground https://github.com/Keychron/qmk_firmware.git ../keychron-qmk-firmware
cd ../keychron-qmk-firmware && git submodule update --init --recursive
brew install qmk/qmk/qmk # plus an ARM cross-compiler for this board's STM32L432
qmk config user.qmk_home=../keychron-qmk-firmware
git apply ../ai-agent-macropad/qmk-userspace/keyboards/keychron/k1_pro/k1_pro.c.patch
# (already cd'd into ../keychron-qmk-firmware above — the patch's paths
# are relative to that repo's root, so no --directory needed here)
cd ../ai-agent-macropad/qmk-userspace
QMK_USERSPACE="$(pwd)" qmk compile -kb keychron/k1_pro/ansi/rgb -km ai_agent_macropad
To flash, per Keychron's own readme: connect the USB-C cable, toggle the board's Mac/Win mode switch to Off, hold down Esc (or the reset button underneath the spacebar), then toggle the switch to Cable:
QMK_USERSPACE="$(pwd)" qmk flash -kb keychron/k1_pro/ansi/rgb -km ai_agent_macropad
Then, same as the Air75 board, verify the wire protocol directly before trusting the daemon to
it — python3 hid_bringup_test.py — and only move on once you've watched the real LEDs cycle
through every state correctly.
Slot wiring, VIA reassignment, and the VIA/daemon exclusivity rule are all the same as the
Air75 board above — same 4 default slots (PageUp/PageDn/Home/End),
same ai_agent_macropad-named keymap directory, same idle-release behavior for VIA access,
same via.json-loading Design-tab step (load
this board's via.json
instead, which extends Keychron's own official VIA definition for this board rather than
replacing it). One difference: this board's 13 stock custom keycodes (left/right Option, left/right
Cmd, Task View, File Explorer, Screenshot, Cortana, Siri, 3 bluetooth host slots, battery level)
use up less of VIA's 32-entry customKeycodes budget than the Air75 board's 24 do, so all 12
AI_AGENT_KEY_0..11 slots are nameable ("AI Slot 0".."AI Slot 11"), not just 8 of them.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 daemon.py
The daemon auto-detects the pad (pad_link.discover_hid_pad()),
trying each board in hid_protocol.KNOWN_HID_PADS (NuPhy Air75 V2,
Keychron K1 Pro) in turn — sending each candidate raw-HID interface a
ping and attaching to whichever one answers hello first via
discover_hid_device(). No need to look up device paths by hand or
update them after a replug.
Needs pip install hid (already in requirements.txt) plus the
native hidapi library (brew install hidapi on macOS) — both
optional, and skipped gracefully (falls back to headless, same as
finding no pad at all) if either is missing.
If no pad is found, the daemon still runs — it just logs what it would send instead of writing to the device. This lets you develop against the socket/slot-mapping logic without any hardware plugged in.
The daemon only holds the pad connection open while at least one
agent session is active (Daemon._reconcile_pad() in
daemon.py) — it's released IDLE_CLOSE_GRACE_SECONDS (5s by
default) after the last session ends, or shortly after startup if the
daemon starts with none running, and reacquired lazily on the next
SessionStart. This is what lets the VIA app
share the same raw HID interface without you having to manually stop
the daemon first — see that section for the exclusivity details.
Before it starts accepting hook events, the daemon also seeds slots
for any Claude Code sessions that were already running — e.g. a
daemon restart while sessions are mid-conversation — by shelling out
to claude agents --json (Claude Code ≥2.1.224) and allocating a slot
per session it reports, so the pad shows them immediately instead of
waiting for each one to happen to fire a hook event first. Requires an
up-to-date claude on PATH; an older CLI (or none at all) just
means seeding finds nothing, and pre-existing sessions fall back to
picking up a slot lazily on their first hook event instead. Every
other slot — anything not claimed by a real session in that
claude agents --json output — gets explicitly cleared to off, so a
slot left glowing by a session that died without a clean SessionEnd
(a crash, kill -9, or a previous daemon run that never shut down
properly) doesn't linger forever; the pad has no way to know the old
daemon process is gone, so nothing else would ever revisit that slot
otherwise (see Daemon.seed_existing_sessions() in daemon.py).
This seeding path is Claude-Code-specific — there's no equivalent
codex agents --json — so a pre-existing Codex session just falls
back to the same lazy-allocation-on-first-hook-event behavior as
everything else.
To exercise the daemon before wiring up real hooks (or anytime you don't
have a live agent session handy), use fake_hooks.py to simulate
a session's hook lifecycle against a running daemon:
python3 fake_hooks.py # one simulated Claude Code session
python3 fake_hooks.py --sessions 3 # three concurrent sessions, staggered
python3 fake_hooks.py --agent codex # simulate a Codex CLI session instead
Each run walks through SessionStart → prompt → tool calls (including
one that should light the slot up as "question" — AskUserQuestion for
Claude Code, PermissionRequest for Codex) → Stop → SessionEnd,
with pauses in between so you can watch the pad react in real time.
Each agent needs its own copy of this daemon's config directory and
its own hook adapter script — daemon.py creates the directory itself
on startup (it hosts daemon.sock and events.log too), so this step
just needs to happen before the first real hook fires:
mkdir -p "$HOME/.ai-agent-macropad"
Both adapter scripts below need jq and a nc build that supports
Unix-domain sockets (-U, e.g. macOS's built-in nc) on PATH.
-
Copy the script and make it executable:
cp claude/hook.sh "$HOME/.ai-agent-macropad/hook-claude.sh" chmod +x "$HOME/.ai-agent-macropad/hook-claude.sh" -
Merge the
"hooks"block fromclaude/example_hook_settings.jsoninto your Claude Codesettings.json(global~/.claude/settings.jsonor a project's.claude/settings.json). It registers$HOME/.ai-agent-macropad/hook-claude.shas a command hook for every eventhandle_hook_event()cares about (SessionStart,UserPromptSubmit,PreToolUse,PermissionRequest,PostToolUse,PostToolUseFailure,Notification,Stop,SubagentStop,SessionEnd). TheNotificationentries split onmatcher(agent_needs_inputvs.idle_prompt) and passMACROPAD_NOTIFICATION_TYPEas an env var, since that's the reliable way to know which subtype fired for a given invocation (Notification:permission_promptitself is not wired up — seehook_to_state's docstring for why).
claude/hook.sh reads the hook's JSON payload from
stdin (Claude Code already includes hook_event_name and session_id
in it) and forwards it to ~/.ai-agent-macropad/daemon.sock via nc -U,
after using jq to fill in a few fields the payload doesn't reliably
carry on its own:
agent, always"claude-code"— bookkeeping only (seeDaemon.session_agentsindaemon.py), doesn't affect state mapping.notification_type, from theMACROPAD_NOTIFICATION_TYPEenv var set by the matcher branch insettings.json.tmux_pane, from the script's own$TMUX_PANE(empty if not running inside tmux).controlling_tty, atSessionStartonly: Claude Code runs hook commands detached with no controlling terminal, so the script instead reads the parent process's tty viaps -o tty= -p "$PPID"— the process Claude Code actually spawned still has one.
It fails open by design (redirects nc's output away, always exits 0)
so a daemon that isn't running never blocks a tool call or a session,
and caps the socket write at one second so PreToolUse/PostToolUse —
which fire on every tool call — stay fast.
Codex CLI's own hooks system (distinct from its older, more limited
notify config key) turns out to use almost the exact same event
vocabulary as Claude Code's — same event names (SessionStart,
UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse,
Stop, SubagentStop, SessionEnd), same delivery (JSON on stdin),
and even the same hooks.json schema shape — so codex/hook.sh is
almost identical to claude/hook.sh; see its comments for the couple
of places they diverge, and hook_to_state's
docstring for what that means for pad states.
-
Copy the script and make it executable:
cp codex/hook.sh "$HOME/.ai-agent-macropad/hook-codex.sh" chmod +x "$HOME/.ai-agent-macropad/hook-codex.sh" -
Wire up the
"hooks"block fromcodex/example_hooks.json, which points every relevant event at$HOME/.ai-agent-macropad/hook-codex.sh. Codex discovers hooks from~/.codex/hooks.json,~/.codex/config.toml's inline[hooks]tables, or the equivalent pair inside a project's own.codex/— see Codex's hooks reference for the exacthooks.jsonvs.config.tomlsyntax. What was actually verified working here (realcodex execrun,codex-cli 0.149.0) was the global~/.codex/config.tomlinline-table form — a project-local.codex/hooks.jsondid not fire in that same version despite matching Codex's documented format, so if hooks silently don't fire for you, tryconfig.tomlbefore assuming something else is wrong. -
New hooks need to be trusted before Codex will run them — the first time, an interactive
codexsession prompts you to review and trust them. For non-interactive use (codex exec, scripts, CI), pass--dangerously-bypass-hook-trustinstead — as the name warns, only do this for hook sources you already trust (i.e.codex/hook.shas shipped in this repo, not an arbitrary command).
Known gaps versus Claude Code (see hook_to_state's docstring in
daemon.py for the full reasoning): Codex has no PostToolUseFailure
event, so a failed Codex tool call still reports as a normal
PostToolUse (state stays working, never error) — there's no
reliable field in that payload to tell success from failure apart.
Codex also has no Notification event, so the waiting (idle 60s+)
state never fires for a Codex session — only Claude Code has an
equivalent. Everything else, including PermissionRequest -> question,
works the same as Claude Code.
pip install -r requirements-dev.txt
python3 -m pytest
All tests run against fakes — no real HID device, socket, or hardware
needed (not even the native hidapi library — a fake hid module
stands in for it):
daemon.py's logic (hook_to_state,SlotManager,Daemon.handle_hook_event, stall escalation, window-dispatch fallthrough) is tested directly, withsubprocess.runandHidPadLink.write_jsonswapped for recording fakes viamonkeypatch.pad_link.HidPadLinkanddiscover_hid_device()are tested against a fakehidmodule (tests/test_hid_pad_link.py) — including a fake raw-HID interface that only answers on the rightusage_page/usage, like the real board's raw HID endpoint alongside its normal keyboard interfaces — and itsopen()/close()reentrancy (safe to call repeatedly on the same instance, needed by the idle-release cycle below).hid_protocol.py's report encode/decode round-trips (tests/test_hid_protocol.py).- The slots-from-handshake path (
HidPadLink.handshake(),Daemon.apply_handshake()) is tested intests/test_pad_handshake.py, including the timeout/no-reply/headless cases. - Startup seeding of pre-existing sessions (
discover_running_sessions(),Daemon.seed_existing_sessions(), and the tty/tmux-pane backfill helpers) is tested intests/test_seed_existing_sessions.py, withsubprocess.runswapped for a recording fake the same way as the other discovery tests. - The idle-release orchestration (
Daemon._reconcile_pad(),Daemon._kick_reconcile()— opening the pad when a session starts, closing it afterIDLE_CLOSE_GRACE_SECONDSonce the last one ends, and staying open if a new session starts before that delay elapses) is tested intests/test_pad_idle_release.pyagainst a fake pad that just tracks open()/close() calls. - Codex-shaped hook payloads (real field names and values, including
its
tool_name: "Bash"for shell calls — confirmed live, not guessed) are exercised intests/test_codex_hook_mapping.py, mostly as regression insurance thathook_to_state()stays agent-agnostic rather than re-testing mapping rules already covered elsewhere.
Each line written to ~/.ai-agent-macropad/daemon.sock is a single JSON
object with (at minimum) hook_event_name and session_id — this
shape isn't specific to any one agent, just to whichever adapter script
produced it (see Agents tested and How it fits
together). Recognized fields:
| Field | Used for |
|---|---|
hook_event_name |
Selects the resulting pad state (see below) |
session_id |
Identifies which pad slot this event belongs to |
agent |
Which agent sent this ("claude-code", "codex") — bookkeeping/logging only, doesn't affect state mapping; defaults to "claude-code" if absent |
cwd |
Project folder name — used for VS Code/IntelliJ window-dispatch matching (QMK pads are RGB-only, no on-device label) |
tool_name |
Distinguishes attention-worthy tools (AskUserQuestion, ExitPlanMode) and labels the slot during PreToolUse |
notification_type |
Distinguishes Notification subtypes (agent_needs_input, idle_prompt, ...) — Claude-Code-only, see Agents tested |
tmux_pane |
tmux pane id, for the "bring to front" key-press dispatch |
controlling_tty |
Terminal.app tty, for the same dispatch when not in tmux |
hook_event_name maps to a display state roughly as:
| Event | State |
|---|---|
SessionStart |
idle |
UserPromptSubmit, PostToolUse |
working |
PreToolUse (generic tool) |
tool_running |
PreToolUse with AskUserQuestion/ExitPlanMode (Claude-Code-only), or PermissionRequest |
question |
PostToolUseFailure (Claude-Code-only — no Codex equivalent) |
error |
Stop |
done |
Notification (agent_needs_input) — Claude-Code-only |
question |
Notification (idle_prompt) — Claude-Code-only |
waiting |
SessionEnd |
slot cleared |
Claude Code and Codex CLI send this same hook_event_name vocabulary
(confirmed against both agents' own hooks references, and — for
Codex — against real hook payloads captured from a live codex exec
run), so hook_to_state() in daemon.py is shared rather than forked
per agent. The rows marked Claude-Code-only above are the only places
they diverge; every other row applies to both agents identically.
A PreToolUse with no matching PostToolUse/PostToolUseFailure within
STALL_THRESHOLD_SECONDS (default 10s) is escalated to tool_stalled
(blinking purple) as a backstop, since Notification:permission_prompt
isn't reliable enough to depend on alone. This deliberately stops short
of claiming question (definitely blocked on you) — the daemon can't
actually tell whether a stalled tool call is an unreported permission
prompt or just a slow tool, so tool_stalled only claims "this is
taking a while." If a definite question signal (PermissionRequest,
Notification:agent_needs_input) does arrive for that same pending
call, the stall tracking for it is dropped — the slot's already showing
a stronger, more specific state than a guess, and shouldn't get
clobbered back to tool_stalled once the threshold elapses from the
original PreToolUse.
Slots are allocated first-fit and freed on SessionEnd. The number of
slots comes from the pad's own hello handshake at startup (whatever
a QMK-based pad reports — see Daemon.apply_handshake() in
daemon.py), with NUM_SLOTS (12) used as a fallback if the pad is
headless or doesn't answer the handshake in time.
Sessions already running when the daemon starts are seeded into slots
up front via claude agents --json, rather than waiting for their next
hook event — see Daemon.seed_existing_sessions() in daemon.py and
Run the daemon above.
Fixed-size 32-byte raw HID reports in both directions — see
hid_protocol.py for the encode/decode helpers and exact byte layout:
| Byte 0 (type) | Direction | Bytes 1-2 |
|---|---|---|
MSG_PING |
daemon → device | (none) |
MSG_HELLO |
device → daemon | device id, slots |
MSG_SLOT |
daemon → device | slot index, state (0-31, see below) |
MSG_KEY |
device → daemon | slot index |
ping/hello is the handshake discover_hid_device() uses to confirm
a given raw-HID interface is actually the pad, not some other board's.
State bytes are hid_protocol.STATE_TO_CODE's values (idle=0,
working=1, waiting=2, done=3, error=4, question=5,
tool_running=6, tool_stalled=7, ..., off=31) — the same values
the QMK firmware's own state enum mirrors. off=31 is deliberately
pinned well above the states defined today rather than "whatever's
defined last" — adding a future state only means picking the next
unused number below it, never renumbering off (and the QMK side's
state <= STATE_OFF bounds check, which is anchored to its value)
again. Values in between that are reserved-but-unused today, or a
value newer than what a given firmware build understands, render as
the "unknown" fallback color described above. There's no separate
"clear" report — an RGB-only pad has no label to clear, so a cleared
slot is just MSG_SLOT with state off (fully dark — distinct from
idle's dim glow).
A key press is sent on key-down only (no key-up equivalent — see
MSG_KEY above) and, on the daemon side, logs which session it
corresponds to and attempts to bring that session's window to the
front (tried in order: tmux pane, Terminal.app tab by tty, VS Code
window by project name, IntelliJ IDEA window by project name) — all
via AppleScript, so this is macOS-only for now.
- Console: human-readable,
INFOlevel. ~/.ai-agent-macropad/events.log: rotating (5MB × 3 files) raw event log — every socket line (parsed or not), every state mapping decision, and every window-dispatch attempt/result. Useful for diagnosing a framing or mapping bug after the fact without reproducing it live.
This is early-stage, but the path from a real agent session to the pad now works end to end:
- ✅ Claude Code hook wiring (
settings.jsonblock +hook.sh) - ✅ Codex CLI hook wiring (
hooks.json/config.tomlblock +hook.sh), verified with a realcodex execrun - ✅ Socket server + hook-event → pad-state mapping, shared across agents
- ✅ HID protocol to/from QMK keyboards (e.g. NuPhy Air75 V2), with pad auto-discovery
- ✅ Slot allocation for concurrent sessions (any mix of agents)
- ✅ Key-press → bring-window-to-front dispatch (macOS)
- ✅ Startup seeding of already-running sessions (
claude agents --json) — Claude-Code-only, see Agents tested - ✅ Idle-release: the pad connection closes when no session is active, freeing it for VIA
⚠️ Keychron K1 Pro (ANSI) QMK keymap — written against Keychron's own official firmware source, unverified on real hardware (see QMK keyboard (Keychron K1 Pro, unverified))
Earlier versions of this project also supported the Adafruit MacroPad
RP2040 over USB serial
(CircuitPython, a 12-key macropad with an OLED label per slot). That
support has been removed — this project has moved fully to QMK/HID
boards — but the firmware, host-side serial transport, and docs are
still browsable at the last commit that had them:
rp2040/
(and that commit's
README
for the full serial protocol writeup and build steps).
MIT — see LICENSE.


