<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Claudedeck on Nick Liu - Software Engineer</title>
    <link>/tags/claudedeck/</link>
    <description>Recent content in Claudedeck on Nick Liu - Software Engineer</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <managingEditor>nickboy@users.noreply.github.com (Nick Liu)</managingEditor>
    <webMaster>nickboy@users.noreply.github.com (Nick Liu)</webMaster>
    <copyright>2026 Nick Liu</copyright>
    <lastBuildDate>Sun, 10 May 2026 22:48:37 -0700</lastBuildDate><atom:link href="/tags/claudedeck/index.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title>Five Stream Deck keys, N Claude sessions: LRU that keeps the order I see</title>
      <link>/posts/lru-session-eviction/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/lru-session-eviction/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;A Stream Deck has five session keys. I usually have six or seven Claude&#xA;Code sessions running. When a new one shows up, the muscle memory test&#xA;isn&#39;t &#34;does the right session get evicted&#34; — it&#39;s &#34;do the four&#xA;survivors stay on the keys they were already on.&#34;&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Two bits of context for anyone new to the stack: Stream Deck is Elgato&amp;rsquo;s&#xA;USB grid of programmable LCD keys, and a &amp;ldquo;session&amp;rdquo; here is a single&#xA;Claude Code conversation — &lt;code&gt;claude&lt;/code&gt; running in one terminal tab, with its&#xA;own working directory, its own context window, its own history. LRU&#xA;stands for &amp;ldquo;least-recently used,&amp;rdquo; the standard cache-eviction policy:&#xA;when you need to make room, drop the entry nobody has touched in the&#xA;longest time.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>Holding HTTP open for 590 seconds so a Stream Deck key can approve a tool call</title>
      <link>/posts/permission-round-trip/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/permission-round-trip/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;Claude Code wants to run a shell command. I want to press a physical Stream Deck key — the YES key, two inches to the left of my keyboard — to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I&#39;ve been pulled into a meeting and come back. The trick is that Claude Code&#39;s hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Setup, for anyone who hasn&amp;rsquo;t seen this stack before: Claude Code is Anthropic&amp;rsquo;s terminal CLI for Claude, and one of its hook events — &lt;code&gt;PreToolUse&lt;/code&gt; — is a script Claude spawns and waits on before running a tool like &lt;code&gt;Bash&lt;/code&gt; or &lt;code&gt;Edit&lt;/code&gt;. The script&amp;rsquo;s stdout decides &amp;ldquo;allow&amp;rdquo; / &amp;ldquo;deny&amp;rdquo; / &amp;ldquo;ask&amp;rdquo;. Stream Deck is Elgato&amp;rsquo;s USB grid of programmable LCD keys. The plumbing I&amp;rsquo;m describing here lives in a daemon — a background process at &lt;code&gt;127.0.0.1:9127&lt;/code&gt; — that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see &lt;a href=&#34;/posts/claude-code-hooks-reality/&#34; &gt;the hooks-reality post&lt;/a&gt;.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>I polled an undocumented endpoint for 18 hours. The data was on stdin.</title>
      <link>/posts/plan-usage-statusline-pivot/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/plan-usage-statusline-pivot/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Quick framing: &lt;em&gt;Claude Code&lt;/em&gt; is Anthropic&amp;rsquo;s terminal CLI for Claude; &lt;em&gt;Claude Max&lt;/em&gt; is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP &lt;em&gt;429&lt;/em&gt; is &amp;ldquo;Too Many Requests&amp;rdquo; — the server&amp;rsquo;s polite way of saying &amp;ldquo;back off.&amp;rdquo; &lt;em&gt;Retry-After&lt;/em&gt; is the response header that tells the client how long to wait. &lt;em&gt;OAuth&lt;/em&gt; is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the &lt;em&gt;statusline&lt;/em&gt; — the same one I covered in &lt;a href=&#34;/posts/statusline-side-channel/&#34; &gt;the statusline side-channel post&lt;/a&gt; — is the script Claude Code spawns every turn with a JSON blob on stdin.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>I split my daemon in two so a Node subprocess could own the PTY</title>
      <link>/posts/pty-wrap-migration/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/pty-wrap-migration/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;I built a Claude Code permission gate that holds an HTTP response open until a Stream Deck key is pressed. Then I needed to inject a keystroke into Claude Code&#39;s own TTY so a key press could write `1\r` straight into Claude&#39;s stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell&#39;s PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Quick grounding before the story: a &lt;em&gt;PTY&lt;/em&gt; — pseudo-terminal — is the kernel object every interactive shell talks to. It&amp;rsquo;s a pair of file descriptors, master and slave; the program reads/writes the slave end as if it were a real terminal, and anything you write to the master end looks to that program like a human typing. The &lt;em&gt;TTY&lt;/em&gt; is the slave end seen from the child&amp;rsquo;s side. &lt;code&gt;node-pty&lt;/code&gt; is Microsoft&amp;rsquo;s library that gives a JavaScript parent process a writable handle to the master. &lt;em&gt;Bun&lt;/em&gt; is a JavaScript runtime — Node&amp;rsquo;s faster sibling — and &lt;em&gt;Node CommonJS&lt;/em&gt; is plain old &lt;code&gt;require()&lt;/code&gt;-based Node, no transpile step. The story below is about which runtime owns the PTY.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>launchctl unload returned 0. The daemon was still running. KeepAlive raced.</title>
      <link>/posts/launchd-bootstrap-debugging/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/launchd-bootstrap-debugging/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;`launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the &#34;unload succeeded&#34; line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(One-paragraph grounding if launchd isn&amp;rsquo;t your daily driver: &lt;em&gt;launchd&lt;/em&gt; is macOS&amp;rsquo;s init system — the equivalent of &lt;code&gt;systemd&lt;/code&gt; on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A &lt;em&gt;LaunchAgent&lt;/em&gt; is a per-user launchd job, defined by an XML &lt;em&gt;plist&lt;/em&gt; — property list — at &lt;code&gt;~/Library/LaunchAgents/&amp;lt;name&amp;gt;.plist&lt;/code&gt;. &lt;em&gt;KeepAlive&lt;/em&gt; is one of the plist keys; set it to &lt;code&gt;true&lt;/code&gt; and launchd will respawn the job whenever it exits. &lt;em&gt;&lt;code&gt;launchctl&lt;/code&gt;&lt;/em&gt; is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think &lt;code&gt;systemctl&lt;/code&gt; driving &lt;code&gt;systemd&lt;/code&gt; unit files. The Stream Deck plugin and its daemon are described in &lt;a href=&#34;/posts/tcc-cdhash-trap/&#34; &gt;the TCC cdhash trap post&lt;/a&gt; if you want the project context.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>TCC pins your Accessibility grant to a cdhash. Every rebuild breaks it.</title>
      <link>/posts/tcc-cdhash-trap/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/tcc-cdhash-trap/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;My daemon&#39;s preflight log said `osascript is not allowed assistive access. (-1719)`. System Settings disagreed — the entry was right there, toggled on. Spoiler: ad-hoc codesigning pins TCC&#39;s designated requirement to the binary&#39;s cdhash, and `bun build --compile` produces a different cdhash on every rebuild.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;I&amp;rsquo;m building a Stream Deck plugin called &lt;a href=&#34;https://github.com/nickboy/claudedeck&#34;  target=&#34;_blank&#34; rel=&#34;noreferrer&#34;&gt;ClaudeDeck&lt;/a&gt; — Stream Deck is Elgato&amp;rsquo;s little USB grid of programmable keys with LCD displays under each one. The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability — automating other apps — through &lt;strong&gt;System Settings → Privacy &amp;amp; Security → Accessibility&lt;/strong&gt;, the pane you&amp;rsquo;ve probably toggled for tools like Rectangle or BetterTouchTool. So on first install I added the daemon, toggled it on, and got back to work.&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>The Claude Code hooks docs are wrong. Here&#39;s what&#39;s actually on the wire.</title>
      <link>/posts/claude-code-hooks-reality/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/claude-code-hooks-reality/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;I wrote a daemon to listen to Claude Code hooks. My first version read `$CLAUDE_HOOK_PAYLOAD` and logged empty bodies for two days straight. The payload was sitting on stdin the whole time.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;This post is the five gotchas I hit while wiring up &lt;a href=&#34;https://github.com/nickboy/claudedeck&#34;  target=&#34;_blank&#34; rel=&#34;noreferrer&#34;&gt;ClaudeDeck&lt;/a&gt; — a Stream Deck plugin (a small program that runs inside Elgato&amp;rsquo;s Stream Deck app on the USB grid of programmable LCD keys) that talks to Claude Code over its hooks system. Claude Code is Anthropic&amp;rsquo;s terminal CLI for Claude — &lt;code&gt;claude&lt;/code&gt; in your shell — and its hooks are user-defined scripts it spawns at certain points in a session (before a tool call, on session start, on prompt submit). My daemon is a long-running background process the plugin and the hooks both talk to over a local socket. None of the gotchas are exotic. All of them cost me hours. Each one is a place where the docs were either silent, ambiguous, or contradicted by tribal knowledge I picked up from other people&amp;rsquo;s projects.&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>The Claude Code statusline is a per-turn telemetry side channel</title>
      <link>/posts/statusline-side-channel/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/statusline-side-channel/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;Claude Code calls a custom statusline command every turn with a JSON payload on stdin. The payload includes the current context-window fill percentage, model, cost, and cwd. Nothing in the contract says you can only read it — you can fork it to anything you want, and the command stays a statusline.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Quick framing for anyone new to Claude Code: it&amp;rsquo;s Anthropic&amp;rsquo;s terminal CLI for Claude, and the &lt;em&gt;statusline&lt;/em&gt; is the configurable line of text it prints under your prompt every turn — like a shell prompt for the agent. You point at any script in &lt;code&gt;settings.local.json&lt;/code&gt;, Claude pipes a JSON object to it on stdin, and whatever the script writes to stdout becomes the visible line.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>Two Stream Deck SDK quirks that cost me a weekend</title>
      <link>/posts/streamdeck-sdk-quirks/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/streamdeck-sdk-quirks/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;Two undocumented behaviours in the Elgato Stream Deck SDK ate most of a weekend: a per-key title-alignment cache that silently ignores manifest updates, and a `willAppear` event that doesn&#39;t always re-fire after a plugin restart. The fixes are short. Finding them was not.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;(Skip this paragraph if you&amp;rsquo;ve shipped a Stream Deck plugin before. The Stream Deck is Elgato&amp;rsquo;s USB grid of programmable LCD keys — common on streamer desks for scene switching. A &amp;ldquo;plugin&amp;rdquo; is a small program — TypeScript, in my case — that runs as a child process of Elgato&amp;rsquo;s Stream Deck app, registers one or more &lt;em&gt;actions&lt;/em&gt; the user can drag onto keys, and reacts to events like &amp;ldquo;key pressed&amp;rdquo; or &amp;ldquo;key visible.&amp;rdquo; The SDK is &lt;code&gt;@elgato/streamdeck&lt;/code&gt; from npm. A &lt;em&gt;manifest&lt;/em&gt; is a &lt;code&gt;manifest.json&lt;/code&gt; next to the plugin that declares its actions, supported devices, default icons, and per-state defaults like title alignment.)&lt;/p&gt;</description>
      
    </item>
    
    <item>
      <title>What replaced CGEventPost in my Stream Deck daemon</title>
      <link>/posts/tahoe-hotkey-dead-end/</link>
      <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
      <author>nickboy@users.noreply.github.com (Nick Liu)</author>
      <guid>/posts/tahoe-hotkey-dead-end/</guid>
      <description>&lt;div class=&#34;lead text-neutral-500 dark:text-neutral-400 !mb-9 text-xl&#34;&gt;&#xA;  &#xA;I press the Stream Deck key. The daemon logs the press, synthesizes `Cmd+Opt+;` through CoreGraphics, and exits cleanly. Wispr Flow does nothing. Three Apple subsystems and one decompiled Electron bundle later, the working trigger turned out to be a one-line URL.&#xA;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;The plan was the boring kind: Stream Deck key (the physical button on Elgato&amp;rsquo;s programmable USB grid) → WebSocket message → my daemon (long-running background process) → synthesized global hotkey → Wispr Flow&amp;rsquo;s hands-free dictation starts — Wispr Flow is the voice-to-text Mac app that types your speech into the focused window — → I talk → words show up in my editor. I&amp;rsquo;d done variants of this with &lt;code&gt;osascript&lt;/code&gt; (macOS&amp;rsquo;s command-line AppleScript runner) years ago. Should have taken an afternoon.&lt;/p&gt;</description>
      
    </item>
    
  </channel>
</rss>
