Skip to content

Latest commit

Β 

History

31 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ¦† Raft

A duckyPad turned into a live status + control surface for a raft of Claude Code agents β€” running in cmux or Orca.

Raft β€” an original duckyPad in landscape, its keys lit to show live Claude Code agent status per workspace

A group of ducks resting on the water is called a raft. This is your control raft: one physical key per workspace, its LED showing what that agent is doing right now, and a press to jump straight to it β€” plus a row of Claude Code action macros.

The top row is per-workspace keys whose LEDs show live agent status (amber = working, blue = needs you, green = done, red = failed) and whose presses jump to that workspace. The lower rows are Claude Code + host action macros (new workspace, next workspace, jump/unread, plan mode, interrupt, yes/no, /clear, /compact, rewind).

Two hosts, one board. cmux and Orca are both supported, and the hook picks the right one from the firing session's own environment β€” no flag, no config. Which host owns the LEDs at any moment is decided by the profile loaded on the pad, so having both installed is fine. Each host gets its own generated profile: profile_cmux/ and profile_orca/.

Inspired by OpenAI's Codex Micro β€” OpenAI's first physical product, a Work Louder Γ— OpenAI Supply macropad whose dedicated keys light up to show your coding agents' status. Raft reproduces the useful 80% β€” glanceable per-agent status + command keys β€” on an original (2020) duckyPad you may already own. (That's a Claude keycap on the Yes key in the photo.)

Raft is built for Claude Code β€” the status LEDs are driven by Claude Code lifecycle hooks and the action keys are Claude Code + host commands. But the firmware, LED protocol, and layout are agent-agnostic: point the hooks at another agent's events and swap the macro keys, and the same board works for Codex or anything else that can run a hook on start/stop/idle.


The catch (and why this repo exists)

The obvious way to build this β€” have Claude Code hooks send the duckyPad's documented "Set RGB Single" HID command (0x04) to color each key β€” does not work on stock firmware. On the original duckyPad, that command is defined in the firmware header and documented in the HID protocol, but never actually implemented: the dispatcher has no branch for it, so it returns UNKNOWN_CMD (status 6) and nothing lights up. No stock OG-duckyPad firmware (v1.3.0 or the latest v3.0.4) can set a key's color from the host.

Raft fixes that with a tiny custom firmware patch that wires up the reserved command (plus keypress-persistence and a sleep-safe host-override layer), cross-compiled on macOS with a hand-rolled GNU toolchain setup. The minimal version of the patch is also being submitted upstream.

So the interesting half of this project is firmware; the rest is a small, no-daemon glue layer.


How it works

Two independent channels, exactly like the Codex Micro's frosted status keys:

        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ inbound (status) ──────────────────────────────┐
 Claude Code ── hook ──> duckypad_hook.py ──┬─ cmux:  cmux identify        ──> sudo duckypad_led.py <ws> <state>
 (SessionStart/            (detect host from β”‚        (firing session β†’ ordinal)         β”‚
  Stop/Notification…)       the session env) └─ orca:  $ORCA_WORKTREE_ID               HID 0x04 β†’ key LED
                                                      + orca worktree ps  ──> sudo duckypad_led.py --board 1:working 2:needs …
                                                       (repaints all five keys)

        └───────────────────────────── outbound (control) β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 duckyPad key ── USB HID keystroke ──> cmux (⌘1–5 jump) / Orca (⌘1–5 jump) / Claude Code (Esc, /clear, …)
  • Outbound is just a normal USB-HID keyboard profile β€” no special software. The top row sends ⌘1β€“βŒ˜5 on both hosts: cmux reads that as workspace N, Orca as the Nth worktree row in its sidebar. Other keys send Claude Code / host shortcuts.
  • Inbound, cmux: the hook resolves which workspace the firing session belongs to via cmux identify (not the focused one β€” a background agent finishing still lights its own key) and sets that one key.
  • Inbound, Orca: worktree ps is the source of truth, and the hook is only a trigger. On each event Raft reads every worktree's agent state and repaints all five keys in one batched write, so keys track agents Raft never saw start β€” including Codex or Gemini panes, which never fire a Claude hook. The firing session's own state comes from the hook payload rather than ps, because Orca installs a sibling hook on the same events and its own state write may not have landed yet.
  • Either way, opening the HID interface needs root on macOS, so the LED writer runs behind a locked-down NOPASSWD-sudo helper. Each call opens/closes the device (no daemon, no persistent handle), and no-ops silently when the pad is unplugged.

Key layout β€” the cmux profile

Held in landscape with the USB cable at top-left (this is the default profile in profile/profile_cmux/; edit profile/duckypad_build_profile.py to change it). The Orca profile differs in three keys β€” see below.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  WS1   β”‚  WS2   β”‚  WS3   β”‚  WS4   β”‚  WS5   β”‚  ← workspace status LEDs + jump (⌘1–5)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Unread β”‚ New WS β”‚ Next WSβ”‚ Rewind β”‚ Intrpt β”‚  ← navigation / actions
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Yes   β”‚   No   β”‚ /clear β”‚compact β”‚ Auto/Plβ”‚  ← Claude Code actions
β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Key Sends Does LED
WS1–WS5 (top row) ⌘1β€“βŒ˜5 Jump to cmux workspace 1–5 live agent status β€” amber = working, blue = needs you, green = done (fades to off after 2 min), red = failed
Unread βŒ˜β‡§U Jump to most-recent unread workspace static
New WS ⌘N New cmux workspace static
Next WS βŒƒβŒ˜] Next workspace static
Rewind Esc Esc Open the rewind / checkpoint picker static
Intrpt Esc Interrupt Claude static
Yes types yes ⏎ Confirm a prompt static
No types no ⏎ Decline a prompt static
/clear /clear ⏎ Clear the conversation static
/compact /compact ⏎ Compact the conversation static
Auto/Pl ⇧Tab Cycle Claude Code permission mode (normal β†’ auto-accept β†’ plan) static

The top row is the heart of it: each key both shows a workspace's agent status (its LED) and jumps to it (its keystroke). The lower rows only depend on which workspace you just focused β€” jump with the top row, then act.

Physical-key mapping: in this landscape orientation the profile files map by physical key number β€” top row = key3/6/9/12/15, middle = key2/5/8/11/14, bottom = key1/4/7/10/13 β€” and LED index = key-file number βˆ’ 1. See docs/calibration.md.


Key layout β€” the orca profile

Identical to the cmux profile except for two middle-row keys, because the top row is now positional on both hosts:

Key Sends Does LED
1–5 (top row) ⌘1β€“βŒ˜5 Switch to the Nth worktree row in Orca's sidebar live agent status, same colors, plus dim purple = idle
Jump (was Unread) ⌘J Open the switch palette and leave it open β€” Orca has no jump-to-unread static
Next WT (was Next WS) βŒ˜β‡§β†“ Next worktree static

Set Orca's worktree sidebar sort to Manual. Its default (smart) re-sorts by agent state and attention, so rows shuffle while you work and the pad's key 3 stops meaning what it meant a minute ago. Manual sort keys off the order you dragged and ignores agent state entirely. This is the one Orca setting Raft needs.

The keys are labelled 1–5 rather than by project, deliberately: which worktree a row holds is decided in Orca's sidebar, so a name baked into the pad would go stale the moment you rearrange. Rearranging now costs nothing β€” no regeneration, no sudo, no re-push.

The LEDs still need to know which worktree each row holds, and that lives in ~/.config/raft/board.json:

{ "host": "orca",
  "slots": [ { "label": "1", "worktree": "<worktreeId>" },
             { "label": "2", "worktree": "<worktreeId>" } ] }

Write it with scripts/raft_board_init.py by reading your sidebar top to bottom β€” --rows raft radar billing-api β€” rather than hand-writing composite ids.

Why positional, when Orca has no per-worktree shortcut? Orca offers only ⌘1β€“βŒ˜9 (Nth row), βŒ˜β‡§β†‘/↓ (neighbour), ⌘βŒ₯← (history back) and ⌘J (palette) β€” nothing that names a worktree. Raft originally typed a uniqueness-checked query into ⌘J, because Orca's row order looked unknowable. With Manual sort it's stable, and "go to row 3" is true by construction: it can't mistarget, can't be ambiguous, and can't create a worktree. See Known limitations for what it costs.


What you need

  • An original duckyPad (2020) β€” STM32F072, 15 keys, per-key RGB. (Not the Pro; that's a different MCU/flow.)
  • macOS (the sender/hooks assume it; the firmware is cross-platform).
  • cmux or Orca with Claude Code sessions. (Both installed is fine β€” see Known limitations.)
  • Toolchain: brew install --cask gcc-arm-embedded, brew install dfu-util, and a Python 3.10+ with pip install hidapi.

Reproduce it

Four stages β€” each directory has its own README with exact commands.

  1. Firmware β†’ firmware/README.md Clone dekuNukem/duckyPad (base commit 46dffb33), copy in the GCC build files, apply firmware/raft-firmware.patch, make, and DFU-flash. This gives you host-controllable per-key LEDs.

  2. Sender + hooks β†’ sender/README.md sudo bash sender/duckypad_install.sh installs the root-owned LED writer + a tightly-scoped /etc/sudoers.d/duckypad. Then merge hooks/settings.snippet.json into ~/.claude/settings.json.

  3. Profile β†’ profile/README.md Generate the 15-key profile from source (no GUI clicking) β€” duckyScript is compiled to .dsb byte-for-byte with the Configurator's own compiler β€” and push it over HID. --host cmux (default) or --host orca; you can push both and switch with the pad's +/- buttons.

  4. Calibrate & test → docs/calibration.md Confirm the LED→key map for your orientation, then run parallel Claude sessions and watch the raft light up.

Extra step for Orca: declare your board

Orca has no stable positional addressing, so you tell Raft which five worktrees the top row owns. This is the only host-specific setup.

# 1. set Orca's worktree sidebar sort to Manual, then drag the five you care
#    about into the top five rows. See what Orca currently has:
python3 scripts/raft_board_init.py --list

# 2. tell Raft which worktree each row holds, reading your sidebar top to bottom
python3 scripts/raft_board_init.py --rows raft radar billing-api

# 3. generate + push the Orca profile (labels and macros come from the board)
RAFT_CONFIG_SRC=/path/to/duckyPad-Configurator/src \
  python3 profile/duckypad_build_profile.py --host orca
sudo python3 profile/duckypad_push_profile.py --profile orca

# 4. select the 'orca' profile on the pad, then fill the board in immediately
python3 scripts/duckypad_repaint.py

raft_board_init.py refuses a selector matching more than one worktree β€” a wrong LED is a lie, even though a wrong jump is no longer possible. Re-run scripts/raft_board_verify.py after deleting a worktree or rearranging; it exits non-zero and names the row. Steps 3 and 4 are one-time β€” the pad's top row is five fixed keystrokes, so rearranging your board afterwards means re-running step 2 only: no regeneration, no sudo, no re-push.


Layout

firmware/   raft-firmware.patch + GNU build (Makefile.gcc, linker script, GCC startup)
sender/     duckypad_led.py (LED writer), duckypad_hook.py (host→LED bridge), duckypad_install.sh
profile/    build/compile/push scripts + the generated profile_cmux/ and profile_orca/
hooks/      settings.snippet.json (the Claude Code hook block to merge)
scripts/    raft_board_*.py (Orca board), duckypad_repaint.py, probe / status / led-test diagnostics
tests/      stdlib unittest suite β€” python3 -m unittest discover -s tests
docs/       calibration, teardown, superpowers/ (design + implementation plan)

Known limitations

  • OLED stays portrait. The firmware has no 90Β° OLED rotation, so held in landscape the OLED text reads sideways. The keys and LEDs are laid out for landscape; the OLED is a secondary channel (blank keycaps + LED color are the real interface).
  • Uses flash beyond the datasheet spec. The GCC/newlib image (~76 KB) exceeds the STM32F072C8's 64 KB spec. This die physically exposes 128 KB (the DFU descriptor reports it) and it's readback-verified on the test unit β€” but it's technically out-of-spec, so verify the readback on your own device. Fully recoverable: DFU is always available.
  • Interrupt leaves it amber (cmux). Claude Code fires no hook when you interrupt a turn, so an interrupted session stays "working" until your next prompt (then self-corrects). Accepted, not worked around. On Orca this self-heals on the next event from any session, because worktree ps reports interrupted and every event repaints the whole board β€” an interrupted worktree shows dim purple.
  • One key, several panes: the loudest state wins. An Orca worktree can hold several agent panes, but it has only one LED, so Raft reduces them by precedence β€” waiting β†’ blocked β†’ working β†’ done β†’ interrupted β†’ idle. Note done outranks interrupted: interrupt one pane while a sibling pane has just finished, and the key stays green until that green goes stale, rather than turning purple. Single-pane worktrees, which is the normal case, are unaffected.
  • Green auto-fades to off. A finished (green) LED switches itself off 2 min after the turn ends (DUCKYPAD_FADE_SEC to tune), so stale green doesn't linger. Red (failed) does not fade; it stays lit until the next state. cmux does this with a detached, token-cancelled fader β€” any newer state supersedes the pending fade. Orca needs no such bookkeeping: staleness is a pure function of ps output plus the clock, so the hook just schedules an idempotent repaint for when the oldest green expires.
  • cmux only: requires reorderOnNotification off. On cmux both channels address workspaces by sidebar position β€” the LED writer via index+1 and the ⌘1–5 jump via cmux's "select workspace N". cmux's app.reorderOnNotification (default on) bubbles a workspace to the top when it needs attention, which renumbers positions and desyncs the physical board. Set "app": { "reorderOnNotification": false } in ~/.config/cmux/cmux.json and cmux reload-config. Raft's status LEDs replace what bubbling gave you. (Positions still shift if you close a middle workspace or drag rows β€” inherent to positional addressing.) Orca needs none of this, because its board is name-addressed.
  • Orca needs its sidebar sort set to Manual. The top row addresses worktrees by position (⌘1β€“βŒ˜5 = Nth worktree row), and Orca's default smart sort re-ranks rows by agent state and attention β€” so a key silently changes meaning while you work. manual keys off the order you dragged and ignores agent state. This is the only Orca setting Raft depends on.
  • Only rows 1–5 have keys, and only rows 1–9 are reachable at all. Orca's positional binding stops at ⌘9, so a worktree further down the sidebar cannot be jumped to by any keystroke. The Jump key (⌘J) stays as the escape hatch. Collapsing a group also removes its worktrees from the row count, shifting everything below it.
  • Rearranging Orca desynchronises the LEDs until you re-run raft_board_init.py. The keys stay correct by construction β€” ⌘3 is always row 3 β€” but board.json still says which worktree row 3 was, so its colour reports the wrong agent. It's a one-command fix and raft_board_verify.py catches the dead-row case, but nothing detects a silent swap between two live worktrees.
  • Leave Orca's terminalShortcutPolicy on its default orca-first. It is what lets ⌘-shortcuts reach Orca from inside a focused terminal pane. Set to terminal-first, ⌘1β€“βŒ˜5 and ⌘J go to the pane instead and the top row stops working from exactly where you press it. Expect a cosmetic "Terminal shortcut handled by Orca" toast; it dismisses itself. Note this applies only to ⌘ keys β€” the bare-Esc keys (Intrpt, Rewind) always go to whatever has focus, so they reach Claude only when its pane is focused.
  • The pad's loaded profile decides who owns the board. With both hosts installed, the LED writer reads the pad's current profile over HID and refuses writes from the other host, so a cmux session cannot repaint your Orca board. Profiles Raft did not push are treated as unowned and allowed, so a hand-made profile never locks you out.
  • Orca ships its own Claude Code hook on the same lifecycle events, as a sibling entry in the same ~/.claude/settings.json. Raft's entry survived the 1.4.184 β†’ 1.4.185 upgrade here, but after any Orca upgrade run the one-liner in docs/calibration.md to confirm it is still there.
  • Sleep. Set the pad's sleep to a long value or Never β€” waking reloads the profile; the firmware's host-override layer re-asserts status colors on same-profile wake, but Never avoids the whole question.

Credits

  • duckyPad by dekuNukem β€” the hardware and firmware this builds on (MIT).
  • cmux and Orca β€” the workspace/worktree managers Raft reads status from.
  • Concept inspiration: OpenAI's Codex Micro (Work Louder Γ— OpenAI Supply) β€” OpenAI's first physical product.

License

MIT β€” see LICENSE. The firmware patch applies on top of dekuNukem/duckyPad (also MIT).

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages