Skip to content

feat(shortcut-remote): JIT-discovered MCPAQL adapter + sidecar generalization#28

Open
mickdarling wants to merge 12 commits into
mainfrom
feature/shortcut-remote-jit-adapter
Open

feat(shortcut-remote): JIT-discovered MCPAQL adapter + sidecar generalization#28
mickdarling wants to merge 12 commits into
mainfrom
feature/shortcut-remote-jit-adapter

Conversation

@mickdarling
Copy link
Copy Markdown
Contributor

Summary

Reference implementation of the MCPAQL pattern applied via JIT discovery to an undocumented vendor-locked HID device — the Hanvon Ugee Shortcut Remote (XP-Pen Shortcut Remote, USB 0x28bd:0x0202). No vendor SDK, no Ghidra; just observational HID capture → decoded protocol → CRUDE adapter + live HUD + sidecar.

This PR contains:

  • The original adapter + decode reference + capture artifacts (already on this branch from the initial commits — JIT-discovered protocol, HUD, schema).
  • Sidecar generalization (this commit) — PASSTHROUGH extended to support shell-command actions in addition to keystroke synthesis. Default is now empty so users wire only what they need.
  • K7 / K8 firmware-double-fire fix (this commit) — those entries removed; the device's firmware was already sending the same keystrokes via interface 0 while our osascript synthesis hit interface 2's bit transitions, doubling the trigger.
  • SHORTCUT_REMOTE_AUTO_OPEN=1 env var (this commit) — adapter opens the HID device at startup so a standalone sidecar receives HUD WebSocket events immediately, without needing an MCP host to first call a read op.
  • HANDOFF.md (this commit) — session-continuity doc that was generated during the original build but never committed.

Why this is a "JIT" adapter

This adapter was built without any vendor cooperation:

  1. Plug in device, observe raw HID reports (tools/capture-hid.js).
  2. Decode the 12-byte vendor-page report layout from observation (docs/xppen-decode-reference.md).
  3. Map button labels (K1–K11) to HID bits empirically.
  4. Generate a CRUDE adapter (adapter/src/server.js + schema.json + provenance.json).

The capture artifacts (capture/raw-reports.jsonl, capture/discovery-bundle.json) are checked in for reproducibility — anyone can re-derive the schema from those reports.

CRUDE surface (adapter/src/schema.json)

mcpaql_read: list_devices, get_device_info, get_battery_status, get_button_state, wait_for_button_press, get_recent_events, is_device_open, get_hud_url
mcpaql_delete: release_device

Plus a live HUD at http://127.0.0.1:47832/ with a WebSocket events stream that the sidecar consumes.

Pitfalls captured in HANDOFF.md

  • node-hid opens with kIOHIDOptionsTypeSeizeDevice on macOS — be careful which interface you open (vendor page only, by default; opening interface 0 steals keystrokes from XP-Pen daemon).
  • XP-Pen's config XML uses <ACK05> (internal model code), not <ShortcutRemote> (a stale legacy template).
  • Built-in default keystrokes for some keys (K1, K2, K11 in Layer I) live in the app binary and aren't extractable from the XML.
  • HUD assets (hud.html, xppen-mappings.json) are read on-demand per HTTP request so edits hot-reload without restarting the MCP server.

Test plan

  • Plug in XP-Pen Shortcut Remote
  • cd generated/shortcut-remote-mcp/adapter && npm install
  • Spawn adapter via stdio MCP host or run SHORTCUT_REMOTE_AUTO_OPEN=1 node src/server.js
  • Open HUD at http://127.0.0.1:47832/ — should show live keypad state
  • cd ../sidecar && npm install; copy a sample PASSTHROUGH entry into index.js; run node index.js; verify the bound action fires on the press edge
  • Verify XP-Pen daemon resumes when release_device is called

🤖 Generated with Claude Code

mickdarling and others added 12 commits April 28, 2026 10:17
First example demonstrating just-in-time observational HID discovery:
a complete CRUDE adapter for an undocumented vendor-locked keypad,
built end-to-end with no vendor SDK, kernel extension, or published
protocol.

The 12-byte vendor-page (0xFF0A) report layout was derived purely from
observed input reports while a human pressed each button. K# to bit
mapping verified live. XP-Pen Layer I-IV mappings decoded from the
~/.xppen/config.xml ACK05 section (the active device tag — the
ShortcutRemote tag in the same file is a stale legacy template).

Layout:
- adapter/        MCP server (stdio) + HTTP/WebSocket HUD on 127.0.0.1:47832
- sidecar/        Standalone keystroke-synthesis daemon listening to the
                  HUD WebSocket; default passthrough K7 (SuperWhisper)
                  and K8 (Escape), all other buttons LLM-routed
- tools/          capture-hid.js (observational discovery) and
                  parse-xppen-config.js (decodes config.xml mappings)
- capture/        235 raw HID reports + DiscoveryBundle
- validation/     End-to-end smoke test + single-press listener
- README.md       Architecture, decode method, install steps,
                  vision threads (smart-paste, app-context-aware,
                  multi-device fleet)

The HUD HTML hot-reloads per HTTP request so future visualization
changes land on browser refresh without restarting the MCP server.
node-hid uses kIOHIDOptionsTypeSeizeDevice on macOS, so the adapter
filters to vendor-defined pages only (usagePage >= 0xFF00) to avoid
hijacking the OS keyboard interface; release_device hands the device
back to the XP-Pen daemon mid-session.

Demonstrates the three-layer pattern: hardware → MCP-AQL adapter
(observe contract) → sidecar (act contract) → OS or LLM (dynamic
meaning). Generalizes to Stream Decks, MIDI controllers, drawing
tablets, gamepads — any HID input device becomes a context-aware
programmable surface via the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-file durable record of every fact derived during JIT discovery
and config parsing. Source of truth for the device protocol decode;
parser code and decoded JSON outputs are mechanical derivations.

Covers:
- Device USB identification (vendor/product IDs, internal model code ACK05)
- HID interface layout and why we restrict opens to vendor pages
- 12-byte vendor-page report layout for both button/wheel and battery
  heartbeat report families
- K1-K11 to HID bit mapping (verified live)
- Physical layout diagram matching XP-Pen UI graphic at rotation 90
- ~/.xppen/config.xml location and structure (ACK05 active section
  vs ShortcutRemote stale legacy template)
- Layer encoding (Layer I-IV via K, K_1, K_2, K_3 elements)
- Per-key XML entry format (default vs customized)
- Pipe-separated payload format breakdown
- Qt key constants and macOS virtual keycode reference table
- Layer I config snapshot
- The Actid limitation and two recovery paths
  (force-customize vs empirical capture)
- Discovery method walkthrough

If only this file survives, the parser, schema, and adapter can be
rebuilt from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…v var + HANDOFF

Three improvements to the keypad project, none coupled to anything else:

## Sidecar PASSTHROUGH supports shell commands

The `PASSTHROUGH` table now accepts two action shapes:

  1. Keystroke synthesis (existing): `{ mac_code, modifiers, label }`
  2. Shell command (new):            `{ command, args, label }`

Each entry fires on the press edge of the configured button. The keystroke
shape goes through `osascript` (needs Accessibility); the command shape just
spawns the binary detached. Lets users wire any K-button to any local
side-effect — toggling apps, sending signals to other sidecars, running
shell scripts, etc. — without touching code beyond their own config.

## Default PASSTHROUGH is empty

Previously the K7 / K8 hardcoded entries were double-firing: the device's
firmware sends `Opt+Cmd+3` / `Escape` via interface 0 (the keyboard
interface) regardless of who holds interface 2 (the vendor-page interface
this adapter watches), and our `osascript` synthesis was firing the same
keystrokes a second time. Defaulting to empty avoids that double-fire and
keeps the sidecar generic — users add only the entries they want, knowing
firmware already handles its own bindings.

The header comment in `sidecar/index.js` documents both action shapes with
examples.

## SHORTCUT_REMOTE_AUTO_OPEN env var

When `SHORTCUT_REMOTE_AUTO_OPEN=1` is set, the adapter opens the HID device
at startup instead of waiting for the first MCP read call. Useful when
running the sidecar standalone (no MCP host driving the adapter) — the
sidecar starts receiving HUD WebSocket events immediately. Default
behavior unchanged when the var is unset; on-demand open as before.

## HANDOFF.md added

The session-continuity doc that was generated for the original keypad
build but never committed. Mirrors the structure of other generator-style
docs in this repo: mission, deployed surfaces, repo map, what works, what's
open, pitfalls/lessons, how to bring up a fresh session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
These five files were created during the 2026-04-28 session — the
kill-switch CLI script (`kill-shortcut-remote-mcp.sh`), its menu-bar
Swift wrapper (`ShortcutRemoteKillSwitch.swift`), and the build /
install / uninstall scripts that turn the .swift into an .app and load
its launch agent. They've been on disk and used since 2026-04-28 but
were never committed.

Checking them in here before the next commits retire them functionally
in favour of the HUD's Server panel, so the historical record is intact
and a future session can either build on them or delete them
deliberately rather than discovering them as orphan untracked files.

The CLI script (`kill-shortcut-remote-mcp.sh`) survives as the terminal
escape hatch for "stop the adapter when the HUD is unreachable" —
documented in the README's terminal-escape-hatches section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into adapter

Sidecar process retired on 2026-05-02; keystroke synthesis, shell
dispatch, and per-layer key lookup now live in the adapter itself
(see the next commit). This file is kept for git-history reference but
is no longer wired — its launchd plist was bootouted and renamed
`~/Library/LaunchAgents/org.mcpaql.shortcut-remote-sidecar.plist.retired-2026-05-02`,
and nothing on the system runs `sidecar/index.js` anymore.

The diff here just refreshes the source so it's coherent at the moment
it became dead code: the sidecar's PASSTHROUGH table records the K7 →
SuperWhisper / K8 → Escape muscle-memory bindings as they were at
retirement (those moved into the adapter's HARDWIRED_ACTIONS map), and
the rest of the file's layer-aware lookup logic is the same shape that
got moved into the adapter's `actionForKeyAtLayer`.

Why retire: the sidecar fan-out only made sense when the adapter was
stdio-spawned per Claude Code session and couldn't synthesize anything
itself. Once the adapter became a long-lived launchd-managed daemon
holding the HID handle as a singleton, the sidecar was just a second
Node process subscribed to its own host's WebSocket — pure fan-out with
no independent state. Folding it into the adapter gives one process,
one log, one launchd job, one Accessibility-permission grant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The adapter is now a single long-lived launchd-managed process that
does five things from one port (127.0.0.1:47832): HID observer, MCP-AQL
endpoint (streamable HTTP, stateful per-session transports), HUD HTTP
+ WebSocket server, keystroke / shell / AppleScript dispatch (formerly
the sidecar), and SuperWhisper recordings-folder watcher (replaces the
per-mode `script` field SuperWhisper 2.13 dropped from its UI).

Why: simpler to reason about (one process, one log, one launchd job),
eliminates the sidecar/menu-bar fan-out, and makes the adapter's value
proposition concrete — "an MCP-AQL adapter you install and have a
working keypad" rather than three coordinated processes the user has
to remember to start. This is also the project's first real
demonstration of the philosophy that MCP-AQL adapters are capable
opinionated layers in their own right, not minimal pipes between
upstream and an MCP client.

## What landed

### Streamable HTTP MCP transport with per-session servers

stdio → streamable HTTP. Multiple Claude Code sessions can connect
concurrently; each gets its own `Server` instance and session ID, but
the underlying HID device + layer + wheel state are process-wide
singletons fanned out across all of them.

### Adapter as authoritative state holder

* `state.currentLayer` (1-4, advanced by K2; broadcast as `layer_change`)
* `state.wheelValue` (signed integer counter; broadcast as `wheel_state`)
* `state.wheelBinding` (`null` = AI mode; broadcast as `wheel_binding_change`)
* `state.lastAnnotation` (LLM-postable HUD overlay payload; broadcast as `annotation`)

Snapshots of all four go to every newly-connected WebSocket client so
HUDs come up in sync without having to wait for the next event.

### New CRUDE operations (registered in schema.json)

  READ:    get_current_layer, get_wheel_value, get_wheel_binding
  UPDATE:  set_current_layer, set_wheel_value, reset_wheel_value,
           set_wheel_binding, post_annotation, submit_voice_command

`set_wheel_binding` accepts three action shapes: `{mac_code, modifiers?,
label?}` for keystrokes, `{applescript, label?}` for raw AppleScript
(needed for system actions like volume / brightness that aren't
keystrokes), `{command, args?, label?}` for shell exec.

### Hardwired keypad bindings (consistent across all layers)

| Key | Action |
|-----|--------|
| K1  | Open / focus HUD (uses `hud_focus` WS broadcast if a tab is connected, else `open URL`) |
| K2  | Advance layer 1→2→3→4→1 |
| K7  | SuperWhisper default mode (Opt+Cmd+3) |
| K8  | Escape |
| K11 | Voice-command trigger (calls `tools/k11-voice-trigger.sh` — see later commit) |

K3-K6, K9, K10 dispatch per-layer keystrokes from `xppen-mappings.json`.
K11 + the wheel together are the AI input surface (no synthesis on the
wheel by default; `set_wheel_binding` flips that).

### HUD-driven server lifecycle (replaces the menu-bar Kill Switch app)

* `POST /control/restart` — `launchctl kickstart -k`
* `POST /control/stop`    — clean exit (launchd does not auto-restart;
                            RunAtLoad still applies at next login)
* `POST /control/disable` — `launchctl disable` + `launchctl bootout`
                            (won't auto-restart at login until the user
                            re-enables from terminal — the response
                            body includes the exact re-enable command)
* `GET  /control/status`  — PID, uptime, current layer, wheel value,
                            open MCP session count, device-open flag

### Voice command pipeline

`POST /voice-command` HTTP endpoint and `submit_voice_command` MCP op
both feed into `triggerVoiceCommand()`, which spawns

  claude -p <system_prompt + transcribed text>
         --allowedTools "mcp__shortcut-remote__mcpaql_read
                         mcp__shortcut-remote__mcpaql_update"

The allowlist scopes Claude to just the keypad-config tools — every
other tool still goes through the user's normal hook/permission system.
**Argv ordering matters**: prompt must come BEFORE `--allowedTools`
because the latter is variadic and would otherwise eat the prompt as
tool names. Comment in the code explains.

System prompt teaches Claude when to use which action shape (keystroke
vs AppleScript vs shell) with recipes for common system actions.

### SuperWhisper recordings-folder watcher

`startSuperWhisperWatcher()` watches `~/Documents/superwhisper/recordings/`
non-recursively (avoids cost of recursive-watching the user's massive
recordings backlog). On each new subdirectory it sets up a brief
secondary watch for `meta.json`, then calls `processSuperWhisperMeta()`.

The processor filters on `modeName === "Shortcut Remote command"`,
polls `meta.json` for up to 30 seconds for `rawResult` to populate
(SuperWhisper writes the file empty first and fills in the transcript
later — the gap can be several seconds), then routes the transcript
through `triggerVoiceCommand`. As a side effect it clears the K11
helper's lock file at `/tmp/sr-voice-recording` and restores the user's
previous SuperWhisper mode (saved by the helper to `/tmp/sr-prev-mode`)
so K7 / normal dictation isn't stuck in voice-command mode after the
LLM round-trip completes.

This replaces the per-mode `script` field that SuperWhisper 2.13 dropped
from its UI. We don't depend on the script field at all anymore;
SuperWhisper just records and writes its standard meta.json, and the
adapter does the rest.

### Defensive hardening

* `synthesizeKey` defaults `modifiers` to `[]` when missing. An LLM
  -issued binding without modifiers crashed the daemon mid-session via
  the HID event handler propagating the throw uncaught.
* `performAction` wrapped in try/catch; wheel-binding dispatch
  self-heals on error (clears the binding + posts an HUD annotation
  explaining why) so a bad binding doesn't lock the wheel into a crash
  loop.
* Process-level `uncaughtException` and `unhandledRejection` handlers
  log loudly but stay alive — so a future malformed binding (or any
  other sync throw we missed) can't take down the HID handle and force
  a launchd respawn cycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ering

HUD overhaul matching the rebuilt adapter. The keypad page now reflects
the adapter's authoritative state (layer, wheel value, wheel binding,
LLM-posted annotations) and exposes server lifecycle controls that
replace the retired menu-bar Kill Switch app.

## What landed

### Stable-height key cards

Every key card is now fixed at 92px regardless of layer or whether it
has a binding. No more layout jitter as you switch between layers with
different binding-text lengths.

### Layer banner with flash-on-change

Big monospace "Active Layer X" pill at the top of the keypad panel.
Flashes cyan when the layer changes, with a "← K2" caption so it's
clear the device (not the mouse) just changed it. Layer updates are
driven by `layer_change` WS events, not local clicks — clicking a
layer pill still works as a manual override / preview.

### Special-key rendering

K1 / K2 / K7 / K8 / K11 are rendered as "special" with hardwired labels
that never change with layer. Two visual treatments:

* **Pinned** (purple, K1 / K7 / K8 / K11) — hardwired action across all
  layers
* **Layer-action** (blue, K2) — advances the layer

K3-K6, K9, K10 still cycle their labels per-layer from xppen-mappings.

### "AI Dial" panel

* Big monospace number for the wheel value, flashes purple on each tick
* Wheel-binding label (shows current binding, e.g. "Vol +6%·-6%", or
  "AI input — no keystroke synthesis" when null)
* Annotation overlay box that renders payloads posted via
  `post_annotation` (text + key/value fields + style: info/warn/error)
* Voice-command test input + Send button that POSTs to /voice-command
  so the loop can be exercised without SuperWhisper

### Server panel

Three buttons that POST to `/control/{restart,stop,disable}` with
appropriate confirm dialogs. Status line tells you what was sent. Stop
and Disable both have hover hints explaining the difference (this
session vs persistent across logins) and the exact terminal command
to re-enable after Disable.

### Focus-notification opt-in for K1 → reload-on-K1

When K1 is pressed and the HUD is already open in a foreground tab,
the adapter sends a `hud_focus` WS event instead of running `open URL`,
which would otherwise spawn a duplicate browser tab. The HUD page
listens and reloads itself.

For backgrounded tabs (where in-page JS can't focus the tab without a
user gesture), there's a "🔔 Enable focus notifications" button. Once
the user grants permission once, K1 fires a system notification
("K11 pressed. Click to bring the HUD forward.") whose onclick handler
calls `window.focus()` and reloads. Falls back gracefully (silent
reload-on-return via visibilitychange) if permission is denied or
unsupported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Start/stop toggle for the SuperWhisper voice-command flow, called from
the adapter as K11's hardwired shell-command action.

## How it works

* **First press** (no lock present): saves the current SuperWhisper
  active mode to `/tmp/sr-prev-mode`, switches to the "Shortcut Remote
  command" mode via `superwhisper://mode?key=custom`, fires
  `superwhisper://record` to start recording, drops a lock file at
  `/tmp/sr-voice-recording`.

* **Second press** (lock present, < 60s old): synthesizes the
  SuperWhisper toggleRecording hotkey (default Opt+Cmd+3) via osascript
  to stop the in-progress recording. SuperWhisper then transcribes
  and writes its standard `meta.json`, which the adapter's
  recordings-folder watcher picks up — the watcher is what clears the
  lock file and restores the previous SuperWhisper mode after the
  voice-command pipeline has run claude -p and reconfigured the keypad.

* **Stale lock** (> 60s old): assumed dead from a cancelled recording;
  removed and treated as "not recording", so the next press starts
  fresh.

## Why URL scheme + hotkey instead of just a hotkey

SuperWhisper 2.13 has no per-mode hotkeys (the entire Script section
of the UI was removed in the 2.10 → 2.13 update). The only way to
record in a *specific* mode is to switch to that mode first, then
fire toggleRecording. URL scheme + hotkey is what the SuperWhisper
docs steer toward (their example Apple Shortcut does the same dance).

## Configuration

Env vars (all optional):

  SR_VOICE_MODE_KEY     mode key (default: custom)
  SR_PREV_MODE_FILE     prev-mode storage (default: /tmp/sr-prev-mode)
  SR_LOCK_FILE          lock path (default: /tmp/sr-voice-recording)
  SR_LOCK_TTL           seconds before lock is stale (default: 60)
  SR_TOGGLE_KEYCODE     mac key code for toggleRecording (default: 20 = "3")
  SR_TOGGLE_MODIFIERS   AppleScript modifier list (default: "option down, command down")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## README rewrite

The architecture diagram and operations table were both rewritten to
reflect the unified one-process design. New sections cover:

* Voice control with SuperWhisper (URL scheme + recordings-folder
  watcher pipeline; replaces the obsolete "set the mode's script
  field" instructions, which no longer work in SuperWhisper 2.13)
* HUD as the control surface (Restart / Stop / Stop & disable buttons,
  with the exact re-enable command for the disable case)
* Terminal escape hatches for when the HUD is unreachable
* Layer behavior (which keys are hardwired across layers vs per-layer)
* Install via `~/.claude.json` HTTP transport entry (was stdio)

The directory layout was updated too, marking sidecar/ as RETIRED
2026-05-02 and noting the kill-shortcut-remote-mcp.sh CLI as the
terminal escape hatch.

## handoff/ folder

Three new dated docs for whoever picks up this work next:

* **2026-05-03-handoff.md** — current state of the system, the voice
  loop architecture, and 13 landmines from this session (SuperWhisper
  2.13 quirks, claude -p argv ordering, brightness key codes being
  no-ops, the F-key-above-F15 hotkey convention, wheel-binding action
  shapes, lock-file lifecycle, etc.)
* **2026-05-03-next-brightness-via-betterdisplay.md** — concrete plan
  for the next task: bind the wheel to control screen brightness via
  the newly-installed `betterdisplaycli`, with the same Tink-tick
  feedback as the existing volume binding. Step-by-step, expected
  duration 15-30 min.
* **2026-05-03-optional-followups.md** — three things future-Claude
  *might* want to do but doesn't have to: commit-splitting rationale
  (effectively retroactive — see the 6-commit shape that produced
  this), file unfinished vision threads as design issues
  (configuration-tool rename, floating HUD, layered AI semantics, per
  -key bindings, etc.), and cross-link the two issues filed during
  this session (`MCPAQL/adapter-generator#30` for OAuth scaffolding,
  `MCPAQL/adapter-studio` repo for the self-service vision).
* **README.md** in handoff/ — index pointing at the three.

The pre-existing project-root HANDOFF.md from 2026-04-28 is left in
place as historical context — the new handoff/README explicitly notes
it's superseded by the 2026-05-03 docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six occurrences of `/Users/mick/...` across one Swift file and three
handoff docs replaced with home-relative or placeholder forms. The repo
is public and these paths exposed the developer's username + home
directory layout — not a security issue (the username is already public
on the GitHub account that owns the repo) but worth tidying for anyone
else cloning.

## Changes

* **tools/ShortcutRemoteKillSwitch.swift** — `rootPath()` fallback now
  uses `NSHomeDirectory()` to compute the conventional checkout path
  under the running user's home, instead of hardcoding mick's. Added a
  comment pointing to `tools/build-kill-switch-app.sh` as the proper
  way to inject the real path via the `ShortcutRemoteMCPRoot`
  Info.plist key. The fallback only matters when the .app is run
  without that key set (development / manual builds).

* **handoff/2026-05-03-next-brightness-via-betterdisplay.md** — example
  MCP `set_wheel_binding` payload now uses `$REPO_ROOT/tools/...` as
  shorthand placeholder, with a sentence noting the `command` field
  needs the actual absolute path at runtime.

* **handoff/2026-05-03-handoff.md** — `/Users/mick/Applications/...` →
  `~/Applications/...` for the retired menu-bar Kill Switch app
  references.

* **handoff/2026-05-03-optional-followups.md** — same `/Users/mick/...`
  → `~/...` swap.

Verified: `grep -rn "/Users/mick" generated/shortcut-remote-mcp/`
returns nothing after this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ness

Adds tools/wheel-brightness-step.sh — a defensive wrapper around
betterdisplaycli for use as a `command`-shape wheel binding action.

Defensive design (lessons from the 2026-05-03 incident where
BetterDisplay crashed mid-session and turned the wheel into a beep
machine):

- pgrep precheck: skip the CLI call entirely if BetterDisplay isn't
  running, avoiding multi-second IPC timeouts per wheel tick
- perl-alarm timeout (2s default) wraps the CLI call so a wedged-but-
  running BetterDisplay can't pile up a backlog
- Tink audio feedback fires only on success — silence is the right
  signal that something is wrong; a beep with no observable change
  misleads, and queued audio after a fast-spin against a wedged
  daemon creates a runaway phantom-beep loop

Tunable via env vars: BD_CLI, BD_APP, BR_STEP, BR_TARGET,
BR_CALL_TIMEOUT. Default target is -displayWithMouse (knob-feel:
turn the wheel while looking at the display you want adjusted).

Wired via mcpaql_update set_wheel_binding with action shape
{ command: <this script>, args: ["up"|"down"] }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ements

Extends the `claude -p` voice-loop allowlist with three DollhouseMCP
patterns (`mcp__dollhousemcp__mcp_aql_{read,create,update}`) so voice
commands can compose keypad reconfiguration with Dollhouse element
activation in one shot. Activation is a read-state operation in
DollhouseMCP semantics; create lets voice synthesize new elements;
update modifies existing element parameters.

Delete and execute deliberately deferred (#34) — execute opens
agentic chain-spawning (an activated agent can run a Claude session
that goes off and does arbitrary work) and needs deliberate gating.
Delete is too easy to mis-transcribe. The deferred-opt-in path will
land via the research-mode RFC (forthcoming) which establishes a
broader allowlist tier engaged via an explicit gesture (chord or
multi-tap) and a required-persistence contract.

VOICE_SYSTEM_PROMPT updated with a Dollhouse recipe block: explains
the three allowed tools, calls out the activate-is-read semantic,
instructs the LLM to refuse + annotate if asked for delete/execute,
and requires a single combined annotation when the voice command
spans both a keypad change and a Dollhouse change.

Allowlist remains overridable at runtime via VOICE_ALLOWED_TOOLS env
var on the launchd plist.

Closes #34. Part of #39 (shortcut-remote-mcp epic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant