(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
echokeystroke, printshello, 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.spawnwithstdio: ["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.spawnwith apipefor stdin. Claude’s TUI checksisatty(0)at startup —isattyis 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. (scriptis 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:
- PTY ownership needs Node (because
node-ptydoesn’t work under Bun). - 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:
require("node-pty")andpty.spawnClaude withxterm-256color(the$TERMvalue — tells the child program what terminal capabilities to assume; this is the modern colour-capable default).- Wires
process.stdin → pty.writeandpty.onData → process.stdout, withsetRawMode(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. - Resolves its ancestor PID chain via
cli/src/shellPidResolver.cjs, which walksps -o ppid=,comm=until it hits a process whose parent comm matches/ghostty/i. - Opens a WebSocket to the Bun daemon at
ws://127.0.0.1:9127/wsand sendspty-holder:registerwith{cwd, pid, ancestorPids}. - On incoming
pty-writeevents, callsproc.write(data)— that’s the moment a Stream Deck press becomes1\rin 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
.cjsfile in a TypeScript project isn’t a code smell. It’s a transpile boundary. Node canrequireit without a build step, so the bun → node hand-off is oneBun.spawnand 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 #
/posts/permission-round-trip/— the original hold-open HTTP design this migration replaced./posts/tahoe-hotkey-dead-end/— the parallel macOS keystroke-injection dead end; PTY-write sidesteps the same filter.- ClaudeDeck phased migration plan:
docs/2026-04-22-option-x-a-plan.md(Option X-A; phase 6 documents the shellPid-capture decision). - PTY runner (Node subprocess):
cli/src/ptyRunner.cjs. - Shell PID resolver (walks
ps -o ppid=,comm=until parent matches Ghostty):cli/src/shellPidResolver.cjs. - PTY ring buffer (output capture for debugging):
daemon/src/ptyRingBuffer.ts. - Bun-side spawn of the Node subprocess +
stdio: "inherit"rationale:cli/src/claudedeck.ts(runClaude). - Daemon-side
pty-writerouting + keystroke mapping:daemon/src/server.ts(routePtyWrite,responseToKeystroke). - Bun +
node-ptycompatibility issue: oven-sh/bun#7362 — “node-pty unable to be run from bun”. - Corresponding tracker on the
node-ptyside: microsoft/node-pty#632 — “Bun support”. node-ptylibrary: https://github.com/microsoft/node-pty.