Skip to main content
  1. Posts/

I split my daemon in two so a Node subprocess could own the PTY

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
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's own TTY so a key press could write `1\r` straight into Claude's stdin. Bun can hold HTTP open all day. Bun cannot reliably wrap a child PTY through `node-pty` and capture the parent shell's PID. So I split my daemon: HTTP and WebSocket stay on Bun, and a Node CommonJS subprocess owns the PTY that runs Claude.

(Quick grounding before the story: a PTY — pseudo-terminal — is the kernel object every interactive shell talks to. It’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 TTY is the slave end seen from the child’s side. node-pty is Microsoft’s library that gives a JavaScript parent process a writable handle to the master. Bun is a JavaScript runtime — Node’s faster sibling — and Node CommonJS is plain old require()-based Node, no transpile step. The story below is about which runtime owns the PTY.)

This post is the architectural follow-up to the permission round-trip post — that one ends with an Update footer noting the daemon has shifted from “hold the HTTP response open for 590 seconds” to “fire-and-forget hook + write the keystroke into Claude’s PTY”. This is the why.

Symptom: the hold-open response stopped winning the race
#

The original ClaudeDeck permission gate worked like this. Claude’s PreToolUse hook POSTs to a local daemon. The daemon holds the HTTP response open. The user presses YES on the Stream Deck. The plugin sends permission:respond over WebSocket. The daemon writes {"hookSpecificOutput":{"permissionDecision":"allow"}} into the still-open HTTP response. Claude reads it, treats the tool as approved, runs it.

That works — until Claude’s own y/n terminal prompt renders faster than the hook decision can travel. Then Claude is sitting there with two input channels open at once: the hook (waiting on the held-open HTTP response) and the terminal prompt (waiting on stdin). Whichever lands first wins. With a slow human in the loop and a fast TUI, the terminal prompt wins more often than the hook does. The user presses YES on the Stream Deck, the daemon happily writes its permissionDecision: "allow" into the held response, and Claude — already past that decision — interprets the incoming JSON as the next user message.

The reasonable fix: stop racing the terminal prompt. Write the keystroke straight into Claude’s PTY, as if a human had typed 1\r. One input channel. No race.

To do that, ClaudeDeck has to own the PTY that Claude is running in. Which means claudedeck claude has to spawn claude through node-pty. Which is where Bun gets in the way.

Investigation: Bun’s node-pty integration doesn’t allocate a real TTY
#

node-pty is Microsoft’s PTY library. It exposes pty.spawn(bin, args, opts) and returns a duplex stream (a stream that’s both readable and writable — read() for what the child wrote, write() for what we want the child to see as input) you can read from and write to as if it were a terminal. Under Node.js it works — every Electron terminal app, every VS Code integrated terminal, every Hyper, leans on it.

Under Bun (1.3.x at the time I tried), it doesn’t. On 2026-04-23 I ran the same five-line test in both runtimes — spawn bash, write echo hello\r, read until prompt:

  • Under Node 22: bash starts, prints its prompt, accepts the echo keystroke, prints hello, closes cleanly.
  • Under Bun 1.3.13: bash spawns, immediately exits with SIGHUP (the “hang-up” signal Unix sends when a terminal goes away — a shell whose controlling terminal disappears under it shuts down), no output, no prompt.

This isn’t unique to me. The Bun issue tracker has a multi-year thread on node-pty integration failing under Bun (oven-sh/bun#7362) — the native .node addon (a compiled C/C++ extension Node loads via require(); node-pty has one because spawning a PTY needs forkpty/openpty syscalls) doesn’t resolve _node_module_register symbols against Bun’s flat namespace. The microsoft/node-pty side has the corresponding “Bun support” tracker (microsoft/node-pty#632). Even when the addon loads, TTY allocation through Bun’s spawn path doesn’t produce the controlling-terminal relationship bash/zsh/claude expect, so the child sees its stdin as closed and exits on SIGHUP.

I tried the obvious workarounds first.

  • Bun.spawn with stdio: ["inherit", "inherit", "inherit"]. Gets you a real TTY in the child — but only Ghostty’s TTY, the same one your shell is on. You can’t write keystrokes into it from another process. Inherit means “I am the user’s keyboard”, not “I am a programmable channel to the user’s keyboard”.
  • Bun.spawn with a pipe for stdin. Claude’s TUI checks isatty(0) at startup — isatty is the POSIX call that asks “is file descriptor 0 (stdin) an actual terminal, or just a pipe/file?”. Pipe-backed stdin isn’t a TTY, so Claude refuses to render its interactive UI and exits.
  • script(1) as an external PTY wrapper. (script is a BSD-era CLI that records a session by allocating a PTY and copying output to a file; people sometimes lean on it as a generic PTY allocator.) Works for I/O but obscures the process tree — I lose the direct parent/child relationship I need to capture the shell PID (more on that below).
  • Wait for Bun to fix node-pty. Realistic, but the issue has been open since 2023; I had a feature blocked today.

So one of two paths: ditch Bun entirely, or pay the cost of a runtime split.

Investigation: capturing the parent shell PID has its own constraint
#

Independently, I needed the PID of the shell (the parent zsh / bash that the terminal emulator launched) that owns the Ghostty tab (Ghostty is my terminal emulator; the “tab” is the same thing iTerm or Terminal.app calls a tab) claudedeck claude is running in. The reason is the “jump to tab” feature — when a permission gate fires and the user clicks YES on the Stream Deck from inside Slack, ClaudeDeck activates Ghostty and focuses the correct tab. Ghostty’s AppleScript dictionary (the published list of properties and commands a Mac app exposes to AppleScript — osascript -e 'tell application "Ghostty" to ...') exposes a pid per terminal, and that PID is the controlling shell — not the bun process, not the Claude process. (docs/2026-04-22-option-x-a-plan.md §6 has the full feasibility analysis.)

I tried capturing that shell PID from Bun. The problem: by the time claudedeck claude is running, the process tree looks like this:

ghostty                                            ← Ghostty itself
└── zsh                                            ← controlling shell (Ghostty AS reports this)
    └── bash claudedeck                            ← user typed `claudedeck claude`
        └── bun (the claudedeck CLI binary)        ← what `process.pid` gives Bun
            └── (eventually: claude, if we spawn it here)

Bun is four levels deep from the shell PID Ghostty reports. I can walk up ps -o ppid=,comm= (the BSD ps output format string: ppid is “parent process ID”, comm is “command name” — together that’s a one-line per-process row showing each parent and what binary it’s running) until I find a process whose parent is ghostty, sure. But I have to be a child of that shell to do it — if I move PTY ownership somewhere else (a separate script(1) wrapper, a launchd-managed helper), the ancestor chain rearranges and the resolver returns the wrong PID. The capture has to happen in the same process that’s about to spawn Claude. Which has to be the same process that owns the PTY. Which can’t be Bun.

Root cause: two constraints, one runtime, can’t satisfy both
#

The two requirements compose badly:

  1. PTY ownership needs Node (because node-pty doesn’t work under Bun).
  2. Shell PID capture needs to happen in a process whose ancestor chain still includes the controlling shell.

There’s no Bun-only path that satisfies (1). There’s no “spawn an unrelated helper” path that satisfies (2). The two have to be satisfied by the same process — and that process can’t be Bun.

The rest of the daemon — HTTP hook endpoints, WebSocket fanout to plugins, plan-usage polling, the state store — has zero PTY needs. Bun handles all of it better than Node would (faster HTTP, native Bun.serve, native WebSocket). Throwing Bun out to fix one feature would punish four others.

The constraint isn’t “Bun is wrong” or “Node is wrong”. The constraint is one runtime can’t do both jobs.

Fix: a Node CJS subprocess for the PTY, Bun for everything else
#

What ClaudeDeck does today, in cli/src/claudedeck.ts:

const proc = Bun.spawn([nodeBin, runnerPath, claudeBin, ...args], {
  stdio: ["inherit", "inherit", "inherit"],
});
return await proc.exited;

runnerPath points at cli/src/ptyRunner.cjs — a CommonJS file (deliberately .cjs, not .ts, so Node can require it without a transpile step). It:

  1. require("node-pty") and pty.spawn Claude with xterm-256color (the $TERM value — tells the child program what terminal capabilities to assume; this is the modern colour-capable default).
  2. Wires process.stdin → pty.write and pty.onData → process.stdout, with setRawMode(true) (raw mode = no kernel line buffering or Ctrl-C handling at this level; every keystroke flows through immediately, which is what an interactive TUI needs) so keystrokes flow verbatim.
  3. Resolves its ancestor PID chain via cli/src/shellPidResolver.cjs, which walks ps -o ppid=,comm= until it hits a process whose parent comm matches /ghostty/i.
  4. Opens a WebSocket to the Bun daemon at ws://127.0.0.1:9127/ws and sends pty-holder:register with {cwd, pid, ancestorPids}.
  5. On incoming pty-write events, calls proc.write(data) — that’s the moment a Stream Deck press becomes 1\r in Claude’s stdin.

The Bun daemon doesn’t change shape much. Its permission:respond handler in daemon/src/server.ts gets a new branch: look up the PTY holder by cwd, send a pty-write event over the existing WebSocket. The keystroke mapping lives in responseToKeystroke()allow"1\r", kind=always"2\r", deny"3\r".

The hook endpoint becomes fire-and-forget. It returns {received: true} immediately and lets Claude render its own y/n prompt. The Stream Deck press writes the answer into the prompt directly. One input channel; no held HTTP response; no race.

stdio: ["inherit", "inherit", "inherit"] on the Bun.spawn call is load-bearing. It means Ghostty’s TTY flows bun → node → ptyRunner unchanged, so when node-pty allocates a master/slave pair inside the Node subprocess, the slave end is a proper controlling terminal. Lose the inherit and Claude’s isatty(0) check fails again.

There’s a small bonus: because the Node subprocess is a direct child of the Bun process, which is a direct child of bash claudedeck, which is a direct child of zsh, the ancestor chain the resolver walks is the same one Ghostty’s AppleScript dictionary sees. The shell PID capture only works because the PTY runner is in the same process tree.

The thing that surprised me about PTY-write as IPC
#

Once you’ve got a writable PTY, you have the cleanest cross-process keystroke injection on macOS that I know of. No CGEventPost. No Accessibility permission grant. No CGXSenderCanSynthesizeEvents filter that Tahoe might have tightened. No osascript "tell application System Events to keystroke" Apple events. Just proc.write("1\r") into a file descriptor you already own.

The reason: a PTY is a kernel-level construct. Writes to the master end are byte-for-byte indistinguishable from a human typing on the controlling terminal — the same kernel code path that turns USB-keyboard bytes into stdin reads also turns master-end writes into stdin reads. The target process doesn’t get to discriminate. It also doesn’t need to opt in.

The catch is the master-end has to belong to your process. You can’t pty-write into a TUI you didn’t spawn. For ClaudeDeck this is exactly the right shape — the user opts into this model by typing claudedeck claude instead of claude, and from that point on the daemon has a clean injection channel for as long as the wrapper is alive.

(Could I have done this with Carbon hotkeys synthesizing events into the frontmost terminal? On Tahoe, no — the OS now filters synthesized events from ad-hoc binaries. PTY-write sidesteps the whole filter because the kernel never sees these as “synthesized” events. They’re just bytes on a file descriptor.)

Lessons
#

  • Runtime choice is a constraint, not a preference. When a feature ceiling is the runtime itself, split before you rewrite. Bun’s HTTP and WebSocket throughput are worth keeping; one Node subprocess for one capability is cheaper than porting four working things to a new runtime.
  • PTY-write is the cleanest cross-process keystroke injection on macOS — when you own the PTY. No Accessibility grant, no event-synthesis filter, no osascript. Just bytes on a file descriptor.
  • Process tree placement is a feature. Capturing a parent shell PID requires being a child of that shell. Move the capture into a sibling or launchd-managed helper and the ancestor chain rearranges out from under you.
  • stdio: "inherit" is the seam between runtimes. It’s how Ghostty’s TTY flows bun → node → ptyRunner without losing controlling-terminal-ness. Pipes break this; inherit preserves it.
  • A .cjs file in a TypeScript project isn’t a code smell. It’s a transpile boundary. Node can require it without a build step, so the bun → node hand-off is one Bun.spawn and zero shared compilation.
  • Two simultaneous input channels into a TUI is one too many. If a feature races a process’s existing prompt, the feature loses on a slow human. Replace the race, don’t tune it.

References
#

Related

Holding HTTP open for 590 seconds so a Stream Deck key can approve a tool call

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've been pulled into a meeting and come back. The trick is that Claude Code'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. (Setup, for anyone who hasn’t seen this stack before: Claude Code is Anthropic’s terminal CLI for Claude, and one of its hook events — PreToolUse — is a script Claude spawns and waits on before running a tool like Bash or Edit. The script’s stdout decides “allow” / “deny” / “ask”. Stream Deck is Elgato’s USB grid of programmable LCD keys. The plumbing I’m describing here lives in a daemon — a background process at 127.0.0.1:9127 — 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 the hooks-reality post.)

I polled an undocumented endpoint for 18 hours. The data was on stdin.

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. (Quick framing: Claude Code is Anthropic’s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is “Too Many Requests” — the server’s polite way of saying “back off.” Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline — the same one I covered in the statusline side-channel post — is the script Claude Code spawns every turn with a JSON blob on stdin.)

The Claude Code hooks docs are wrong. Here's what's actually on the wire.

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. This post is the five gotchas I hit while wiring up ClaudeDeck — a Stream Deck plugin (a small program that runs inside Elgato’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’s terminal CLI for Claude — claude 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’s projects.