macro-claude
May 13, 2026 · View on GitHub
Live status of every running Claude Code session on a Logitech MX Creative Console, with single-key focus to the terminal that owns the session.
macOS only. Tested against Claude Code CLI 2.1.101 on macOS 13+.
What it does
If you run several Claude Code sessions in parallel (iTerm2, VS Code integrated terminals, Anthropic Claude Code VS Code extension, any combination), it is hard to notice which one has quietly gone idle and is waiting for your next prompt. macro-claude makes every running session one key on the MX Creative Console. Each key is a dark square with one big state glyph in an accent colour:
| Glyph | State | Meaning |
|---|---|---|
● | Idle | Assistant turn finished, waiting for you (green) |
▶ | Working | Heartbeat is fresh, something is happening right now (blue) |
⋯ | Thinking | Transcript has gone quiet but the process is CPU-busy (cyan) |
‼ | Stuck | Heartbeat is stale and CPU is near zero (amber) |
? | Waiting | Blocked on user approval — plan-mode, permission prompt (lavender) |
✗ | Error | Last turn ended in StopFailure or was interrupted (red) |
· | Gone | Slot not in use (grey) |
Logi Options+ renders the short project name and elapsed turn time
(MM:SS / HH:MM:SS) as the label under each button, so an 8-hour
turn is obvious at a glance. For git worktrees the label shows the
main repo name, not the branch, so all worktrees of one project read
as the same project.
Pressing a key focuses the exact terminal that owns that session. For VS Code integrated terminals and Anthropic Claude Code VS Code extension sessions that's the owning window via a companion extension; for iTerm2 it's the exact session (tab + split) via iTerm2's AppleScript dictionary. Both VS Code and iTerm2 focus paths reach windows on other fullscreen Mission Control Spaces, which Accessibility-API-only tools cannot do.
How it works
macro-claude is not a single program. It is a small pipeline with four
moving parts that share state via files under ~/.claude/:
Claude Code CLI ─┬─ SessionStart / Stop / UserPromptSubmit /
│ Pre/PostToolUse / Notification / StopFailure /
│ SessionEnd — hooks fire during every turn
▼
hooks/session-monitor.sh ───► ~/.claude/session-status/<sid>.json
│
▼
Logi Plugin Service (LPS)
│
▼
plugin/MacroClaudePlugin C# plugin
│
┌────────────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
FileSystemWatcher on ps polling on PIDs JSONL tail scan
~/.claude/session-status/ from sessions/*.json for "[Request
and ~/.claude/sessions/ (once per second) interrupted by user]"
│ │ │
└────────────────────┬───────────────────┘──────────────────────┘
▼
StateResolver.Determine(...)
│
▼
SessionSnapshot (state + elapsed)
│
▼
ClaudeSlotNCommand.GetCommandImage(...) → 80×80 PNG
│
▼
pressed → FocusDispatcher cascade
│
┌───────────────────────────┼────────────────────────┐
▼ ▼ ▼
VS Code bridge (HTTP) ITerm2Client (protobuf) NativeActivator
POST /focus {pid} over Unix socket, (libobjc)
→ terminal.show() in session ActivateRequest NSRunningApplication
owning window by jobPid .activate — plain
│ │ app-level raise
▼ ▼ ▲
VSCodeUrlActivator ITerm2AppleScriptActivator │
/usr/bin/open osascript to iTerm2 │
vscode://file/<root> dictionary, select tab │
(reaches windows in by tty — reaches sessions │
other fullscreen in other fullscreen Spaces │
Spaces) │
│ │
▼ │
AppleScriptActivator ────────────────────────────────────────┘
System Events AXRaise by window title
(single-Space fallback)
The signals behind every state
Claude Code does not fire a hook when you hit Esc or Ctrl+C to
interrupt a turn, and extended thinking can run 30–60 seconds without
writing anything to the JSONL transcript. A naive "hook heartbeat only"
resolver would mark interrupted sessions as working forever and
long-thinking sessions as stuck. macro-claude avoids both by
combining five inputs:
- Hook events — latest event name and timestamps from
~/.claude/session-status/<session_id>.json, written by the bash hook.Notificationevents are further disambiguated by theirmessagefield: "Claude is waiting for your input" resolves to Idle (turn handed back to the user), anything else resolves to Waiting (permission / plan approval — real blocking gate). - JSONL mtime — transcript modified time from
~/.claude/projects/**/<session_id>.jsonl - Process CPU —
ps -o pcpu=on the PID recorded in~/.claude/sessions/<pid>.json - JSONL tail — last 4 KB of the transcript, scanned for the
[Request interrupted by user]marker that Claude Code leaves behind when you abort - PID liveness —
Process.GetProcessByIdon every tracked pid once per second; sessions whose process has exited are reaped and their stale status files cleaned up, so closing a Claude window on one side of the pipe removes the corresponding button within a second.
The final state is resolved by StateResolver.Determine(...), a
pure function with 108 unit tests pinning the behaviour for every
combination. Thresholds are:
- fresh heartbeat window — 3 seconds
- stale heartbeat window — 30 seconds
- CPU active threshold — 1.0 %
- CPU idle threshold — 0.5 %
Components
macro-claude/
├── hooks/ bash session monitor + idempotent installer
├── plugin/
│ ├── MacroClaudePlugin/ C# 8 Logi Actions SDK plugin (the macropad side)
│ └── MacroClaudePlugin.Tests/ C# 8 xunit tests for pure logic (108 tests)
└── vscode-extension/ TS companion extension (HTTP bridge for focus)
hooks/
Two bash scripts, both pass shellcheck --severity=style --enable=all
and both run under macOS's stock /bin/bash 3.2:
session-monitor.sh— hook entry point. Reads the hook JSON from stdin, writes a per-session status file at~/.claude/session-status/<sid>.json. Deletes the file onSessionEnd. DisambiguatesNotificationhook between permission / plan-approval (→waitingstatus) and turn-complete idle-prompt (→idlestatus) by matching on themessagefield.install.sh— idempotent installer. Mergessession-monitor.shinto~/.claude/settings.jsonunderSessionStart,UserPromptSubmit,PreToolUse,PostToolUse,Notification,Stop,StopFailure, andSessionEndhook keys without touching any existing hook entries. Takes a--uninstallflag and always backs up the existingsettings.jsonfirst.
plugin/MacroClaudePlugin/
C# 8 plugin loaded by Logi Plugin Service. Strict lint policy applied project-wide:
TreatWarningsAsErrors=true,Nullable=enable,AnalysisMode=All,EnforceCodeStyleInBuild=true- StyleCop.Analyzers, Roslynator.Analyzers, Roslynator.Formatting.Analyzers
- Every source file uses file-scoped namespace and is nullable-safe
Key classes:
Status/SessionSnapshot— immutable record of a resolved session, including the worktree-awareRepoNameStatus/StateResolver— pure function, the state machine itselfStatus/StatusReader—FileSystemWatcheron two directories, CPU poller on a 1 Hz timer, transcript mtime + interrupt-marker scanner, a reconciliation sweep that drops sessions whose files disappear behind FSEvents' back, and a pid resolver that retries the sessions directory when hook events race ahead of Claude Code writingsessions/<pid>.json. EmitsSessionSnapshotvia events.Status/SlotAssigner— thread-safe first-come first-served assignment ofsession_id → slot index(0..8)Status/SlotBus— static event bus betweenPlugin.Loadand the nine slot commands. Protected by an owner token so a zombie Plugin instance that LPS failed to fully unload cannot corrupt the snapshot store after a reload.Status/GitRepoResolver— filesystem-only resolution of the main worktree basename from a session cwd, so buttons for a git worktree show the repo name instead of the branchActions/SlotCommandBase+ nineClaudeSlot1Command..ClaudeSlot9Commandsubclasses — each is a separatePluginDynamicCommandpinned to a fixed slot index, because the MX Creative Keypad UI in Logi Options+ does not expandAddParameterinto separate draggable entries the way the Loupedeck Live UI doesFocus/FocusDispatcher— the five-path cascade belowFocus/NativeActivator— P/Invoke bridge tolibobjc.A.dylibforNSRunningApplication.activateWithOptions:. Used for plain bundle-level activation, and as the dead-last fallback when the URL-scheme and AppleScript paths below all failFocus/VSCodeUrlActivator— shells out to/usr/bin/open vscode://file/<workspaceRoot>. macOS LaunchServices routes the URL to the existing VS Code window holding that folder, including windows on different fullscreen Spaces (which AX API cannot reach)Focus/AppleScriptActivator— System EventsAXRaiseby window title. Used as a single-Space fallback for VS Code when the URL path cannot determine the workspace rootFocus/ITerm2Client— protobuf over the iTerm2 Python API Unix socket, primary path for iTerm2 session-level focus if the user has turned the Python API on in iTerm2 SettingsFocus/ITerm2AppleScriptActivator—osascriptto iTerm2's own AppleScript dictionary. Zero-configuration fallback used when the Python API is off; walks window/tab/session by TTY and selects the matching session. Works across fullscreen Spaces because the AppleScript commands run inside iTerm2's own process.
plugin/MacroClaudePlugin.Tests/
xunit test project targeting net8.0. Consumes the pure-logic files as
linked sources rather than via a ProjectReference so it stays free of
Loupedeck build targets.
Passed! - Failed: 0, Passed: 108, Total: 108, Duration: 24 ms
vscode-extension/
VS Code extension in TypeScript, written against strict tsconfig.json
(noUncheckedIndexedAccess, exactOptionalPropertyTypes) and the
typescript-eslint strict-type-checked preset.
Publishes a local HTTP bridge on a random port. Per-window lock files
live at ~/.claude/macro-claude-bridge/<sessionId>.lock with an auth
token; stale locks from crashed extension hosts are cleaned up on
next activation. POST /focus {pid: number} walks the target PID's
ancestor chain via ps -axo pid=,ppid= and matches against every
integrated terminal's shell PID and this window's own extension
host PID (for sessions launched by the Anthropic Claude Code VS Code
extension — those children live under the extension host, not a
shell). On a match it returns {focused, terminalName, workspaceName, workspaceRoot}; the plugin then asks open vscode://file/<root>
to raise the correct window, which works across fullscreen Spaces.
For multi-root .code-workspace windows it returns the path to the
.code-workspace file instead of the first folder, so the URL
activates the real multi-root workspace rather than a new
single-folder window.
Runtime requirements
- macOS 13 or newer
- Logi Options+ with Logi Plugin Service running and an MX Creative Console connected
- Claude Code CLI 2.x with hooks configured
jq— used by the bash hook script.brew install jqif not present.- Optional: VS Code with the companion extension (in
vscode-extension/) for point-accurate terminal focus. Works for both terminal-based Claude sessions and the Anthropic Claude Code VS Code extension. - Optional: iTerm2. Session-level focus works out of the box via
AppleScript — no Python API setup required. macOS may prompt
once for Accessibility / Automation permission for Logi Plugin
Service on first use; accept it. If you prefer the protobuf path,
turning on Settings → General → Magic → Enable Python API lets
the plugin use
ITerm2Clientfirst, which is a hair faster per press. Either path reaches fullscreen iTerm2 windows correctly.
Build requirements
- .NET 8 SDK —
brew install dotnet@8 - Node.js 20 + npm — for the VS Code extension
shellcheck— bash linter,brew install shellcheck- Optional:
shfmt— bash formatter
Install
1. Build the plugin and install it into Logi Plugin Service
# from repo root
dotnet build plugin/MacroClaudePlugin/src/MacroClaudePlugin.csproj --configuration Release
The build's PostBuild target writes a .link file into
~/Library/Application Support/Logi/LogiPluginService/Plugins/ that
points at the built DLL. Logi Plugin Service picks the plugin up
automatically — no .lplug4 install needed for local use.
Or install the latest released plugin via Homebrew:
brew tap lexfrei/tap
brew install macro-claude
The formula unpacks the .lplug4 into the brew prefix and prints a
one-liner in caveats that writes the .link file into LPS's plugin
directory. Run that line once, and LPS picks the plugin up on its
next reload.
To produce a distributable .lplug4 package locally:
dotnet logiplugintool pack plugin/MacroClaudePlugin/bin/Release dist/MacroClaudePlugin.lplug4
dotnet logiplugintool verify dist/MacroClaudePlugin.lplug4
2. Install the hooks into your Claude Code settings
Recommended — via the Claude Code Companions marketplace:
# one-time: add the marketplace
/plugin marketplace add lexfrei/ccc
# install the hook plugin
/plugin install macro-claude@claude-code-companions
Alternative — manual install (if you don't use CCC):
bash hooks/install.sh
The manual path is idempotent and creates a timestamped backup of
your existing ~/.claude/settings.json before writing. Uninstall
with bash hooks/install.sh --uninstall.
3. Install the VS Code extension (optional, only needed for VS Code)
Install from the VS Code Marketplace:
code --install-extension lexfrei.macro-claude-bridge
Or build from source:
cd vscode-extension
npm install
npm run compile
npm run package
code --install-extension macro-claude-bridge-*.vsix
4. Configure the macropad
Open Logi Options+ and find the Claude category. It contains
nine separate commands — Claude Session 1 … Claude Session 9
— one per macropad slot. Drag them onto the physical keys of your
MX Creative Console in whatever order you like (one per key).
Sessions are assigned to slots on a first-come-first-served basis
as they appear, so the button layout stays stable: the first
claude you started always lives in Slot 1, the second in Slot 2,
and so on. When a session finishes its slot is released and the
next new session takes it over.
Nine slots matches one full MXCC profile page. This is a hard
cap — adding more would require adding more ClaudeSlotNCommand
subclasses on the plugin side because MX Creative Keypad's
Options+ UI does not expose parameterised commands as separate
draggable entries the way the Loupedeck Live UI does.
Configuration (optional)
All state-resolver thresholds are tunable per machine by dropping a
~/.claude/macro-claude.json file. Every field is optional; a missing
or non-positive value falls back to the constant default used by
StateResolver.
{
"freshHeartbeatSeconds": 3,
"staleHeartbeatSeconds": 30,
"cpuActiveThreshold": 1.0,
"cpuIdleThreshold": 0.5,
"orphanReapSeconds": 300
}
| Field | Default | Meaning |
|---|---|---|
freshHeartbeatSeconds | 3 | Heartbeat is "fresh" if age < N s; working |
staleHeartbeatSeconds | 30 | Heartbeat is "stale" if age > N s; triggers stuck/thinking discrimination |
cpuActiveThreshold | 1.0 | CPU ≥ N % with stale heartbeat → thinking |
cpuIdleThreshold | 0.5 | CPU ≤ N % with stale heartbeat → stuck |
orphanReapSeconds | 300 | Reap session-status files with Pid=0 whose max of hook heartbeat and transcript mtime is older than N s (hard-reboot cleanup) |
The config file is read once in StatusReader's constructor. Changes
at runtime require a plugin reload (touch the .link file or restart
Logi Plugin Service).
Raise freshHeartbeatSeconds if your Claude Code sessions routinely
emit output in chunks wider than 3 seconds — otherwise they will
bounce between working and thinking. Lower cpuActiveThreshold
on machines with noisy background CPU, or raise it if the idle
baseline is high.
Tests and linting
make test # dotnet test on pure logic (108 tests)
make lint-shell # shellcheck hooks
make lint-vscode # eslint + tsc --noEmit on the vscode extension
Continuous integration
The .github/workflows/ci.yml pipeline runs on every push to main and
every pull request with three parallel jobs on ubuntu-latest:
- shellcheck hooks — strict mode (
--severity=style --enable=all) - dotnet build + test — builds the full plugin (using the vendored
PluginApi.dllfromvendor/) with strict analyzers, then runs the xunit v3 test suite (108 tests) via the native in-process runner. - vscode-extension —
npm ci,tsc --noEmit, eslint strict-type-checked,esbuildcompile, andvsce packagesanity check. The resulting VSIX uploads as a workflow artifact on every green run.
Releases
Releases are cut by pushing a tag matching v*:
# make sure you're green locally
make test
make lint
# tag and push
git tag --sign v1.0.0 --message "Release v1.0.0"
git push origin v1.0.0
That triggers .github/workflows/release.yml, which re-runs the full
CI gate, builds the VSIX, creates a GitHub release with installation
notes, and attaches the macro-claude-bridge-*.vsix to it.
Plugin build artifact
CI builds the plugin on every push (using the vendored
PluginApi.dll). For local development, dotnet build produces
the DLL directly and installs it into LPS via a .link file — no
.lplug4 packaging needed.
To produce a distributable .lplug4 for sharing and publish a full
release that includes the Homebrew formula:
make release-plugin # produces dist/MacroClaudePlugin.lplug4
make release-upload TAG=v1.0.0 # attach .lplug4 + .vsix to the GitHub release
make brew-bump TAG=v1.0.0 # rewrite Formula/macro-claude.rb in lexfrei/homebrew-tap
make brew-bump downloads the published .lplug4 from the release,
computes its sha256, regenerates the formula from a template in
scripts/brew-bump.sh, and pushes directly to master of the local
clone of lexfrei/homebrew-tap (default path
~/git/github.com/lexfrei/homebrew-tap).
Troubleshooting
The plugin loads but no keys react. Check
~/.claude/session-status/. If it is empty, hooks are not firing —
re-run bash hooks/install.sh and verify ~/.claude/settings.json
has SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
Notification, Stop, StopFailure, and SessionEnd hook events
pointing at session-monitor.sh.
The plugin does not appear in Logi Options+ at all. Check
~/Library/Application Support/Logi/LogiPluginService/Logs/plugin_logs/MacroClaude.log.
A successful load shows:
INFO | 1 dynamic actions loaded
INFO | macro-claude: StatusReader started
INFO | Plugin 'MacroClaude' version '1.0' loaded from ...
A failure shows:
ERROR | Cannot load plugin from '...'
WARN | Plugin 'MacroClaude' added to disabled plugins list
The most common cause is that the assembly has no concrete
ClientApplication subclass — Logi Plugin Service requires one even
for universal plugins. Make sure MacroClaudeApplication.cs exists and
extends ClientApplication.
Pressing a key lands on a different VS Code window than the target session. The companion extension is missing or stale; the plugin fell through to bundle-level activate which always picks the most-recently-used window. Check:
~/.claude/macro-claude-bridge/contains a<sessionId>.lockfor each VS Code window you have open. Missing means that window's extension did not activate — open Output → Extension Host in that window. Stale extra locks from crashed hosts are cleaned up automatically on next extension activation.- The companion extension is actually installed in VS Code. Run
code --list-extensions | grep macro-claude. - After an upgrade, all open VS Code windows need Developer: Reload Window (⌘⇧P) so the new extension code gets loaded in each extension host.
Pressing a key opens a new VS Code window instead of activating
the existing one. The workspaceRoot returned by the bridge does
not match any window that is currently open with that folder. For
single-folder windows this can happen when Claude Code was launched
from a subdirectory of the workspace root — the session's cwd
points at the subdir, and LaunchServices opens that subdir as a new
window. The fix is in the extension: reload the VS Code window so
the extension returns the real workspaceFolders[0] fsPath (or, for
multi-root windows, the path to the .code-workspace file) instead
of cwd.
Pressing a key raises iTerm2 but lands on the wrong tab. Usually fixes itself on LPS reload. If it keeps happening:
- Check System Settings → Privacy & Security → Automation and
make sure
Logi Plugin Serviceis allowed to controliTerm2andSystem Events. - Check that
ps -o tty= -p <claude pid>returns the tty the target session actually owns. If it returns?, the target process has no controlling terminal (e.g. a background subprocess) and AppleScript matching will not find it. - If you have the Python API turned on, check the
ITerm2Client.FocusSessionByPidAsyncpath by enabling verbose LPS logs —jobPidmatching fails for Claude Code running inside tmux or any shell whereclaudeis not the foreground job.
Everything is off by one second. All timestamps in
~/.claude/session-status/ are Unix seconds, not milliseconds. The
turn_started_s field is always the start of the current turn, not
wall-clock elapsed session age.
Privacy
The plugin only reads files that Claude Code already writes to
~/.claude/:
~/.claude/sessions/<pid>.json— PID, session id, cwd~/.claude/session-status/<sid>.json— hook events (written by us)~/.claude/projects/**/<sid>.jsonl— last 4 KB only, scanned for the interrupt marker string
Nothing is sent off-machine. No telemetry. No network calls except the localhost-only HTTP bridge to the VS Code companion extension.
Status
Working end-to-end:
- Plugin loads into Logi Plugin Service via the LPS reflection
discovery path (protected against the undocumented
ClientApplication-must-exist requirement). - StatusReader watches all three source-of-truth directories,
reconciles ghost sessions that FSEvents dropped, reaps dead
pids, and resolves missing pids against
sessions/<pid>.jsonwhen a hook races ahead of Claude Code. - Seven states: Idle / Working / Thinking / Stuck / Waiting / Error / Gone, each with its own glyph and accent colour.
- FocusDispatcher five-path cascade handles VS Code integrated terminals, Anthropic Claude Code VS Code extension sessions, iTerm2 via AppleScript (with Python API as a faster alternate path), and reaches windows on other fullscreen Spaces.
- 108 tests on the pure-logic core (
make test), shellcheck on the bash hooks,tsc --noEmit+ eslint on the VS Code companion extension.
Known limitation: when two or more Anthropic Claude Code
webview tabs live inside the same VS Code window, the
plugin can only raise the window, not activate a specific tab
within it. The public VS Code / Anthropic extension APIs do
not expose a focusSessionById path.
License
BSD 3-Clause. See LICENSE.