Skip to content

Instantly share code, notes, and snippets.

@tekknolagi
Last active March 10, 2026 19:53
Show Gist options
  • Select an option

  • Save tekknolagi/488f28683a317c01194e66518581d53d to your computer and use it in GitHub Desktop.

Select an option

Save tekknolagi/488f28683a317c01194e66518581d53d to your computer and use it in GitHub Desktop.
Minimal PTY server/client for driving interactive CLIs (lldb, gdb, etc.) over a Unix socket
require "pty"
require "io/wait"
require "socket"
require "json"
# A background PTY server that keeps an interactive CLI alive
# and exposes it over a Unix socket with JSON request/response.
#
# Protocol:
# -> {"cmd": "run", "input": "bt\n", "settle": 0.3, "timeout": 5}
# <- {"output": "...", "alive": true}
#
# -> {"cmd": "quit"}
# <- {"output": "", "alive": false}
#
class PTYServer
SOCK_DIR = "/tmp"
attr_reader :sock_path
def initialize(*cmd, name: "pty", settle: 0.5, timeout: 10)
@sock_path = "#{SOCK_DIR}/#{name}-#{$$}.sock"
File.delete(@sock_path) if File.exist?(@sock_path)
@out, @in, @pid = PTY.spawn(*cmd)
@out.sync = true
drain(settle: settle, timeout: timeout) # eat startup banner
@server = UNIXServer.new(@sock_path)
$stdout.puts @sock_path
$stdout.flush
$stderr.puts "PTYServer listening on #{@sock_path} (pid #{@pid})"
end
def serve
loop do
conn = @server.accept
req = JSON.parse(conn.gets)
resp = handle(req)
conn.puts(JSON.generate(resp))
conn.close
break if req["cmd"] == "quit"
end
ensure
cleanup
end
private
def handle(req)
case req["cmd"]
when "run"
input = req["input"] || ""
settle = req.fetch("settle", 0.3)
timeout = req.fetch("timeout", 5)
@in.print(input)
@in.print("\n") unless input.end_with?("\n")
output = drain(settle: settle, timeout: timeout)
{ "output" => output, "alive" => alive? }
when "drain"
output = drain(
settle: req.fetch("settle", 0.3),
timeout: req.fetch("timeout", 5)
)
{ "output" => output, "alive" => alive? }
when "quit"
@in.puts("quit") rescue nil
output = drain(settle: 0.5, timeout: 2)
{ "output" => output, "alive" => false }
else
{ "error" => "unknown cmd: #{req["cmd"]}" }
end
end
def drain(settle: 0.3, timeout: 5)
buf = +""
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
break if remaining <= 0
if @out.wait_readable([remaining, settle].min)
buf << @out.read_nonblock(4096)
else
break
end
end
buf
rescue Errno::EIO, EOFError
buf
end
def alive?
Process.kill(0, @pid) && true
rescue Errno::ESRCH
false
end
def cleanup
@in.close rescue nil
Process.kill(:TERM, @pid) rescue nil
5.times do
return if Process.waitpid(@pid, Process::WNOHANG) rescue return
sleep 0.1
end
Process.kill(:KILL, @pid) rescue nil
Process.wait(@pid) rescue nil
ensure
File.delete(@sock_path) rescue nil
@server.close rescue nil
end
end
# --- Client helper (for use from another Ruby process or inline) ---
class PTYClient
def initialize(sock_path)
@sock_path = sock_path
end
def run(input, settle: 0.3, timeout: 5)
send_cmd("run", "input" => input, "settle" => settle, "timeout" => timeout)
end
def quit
send_cmd("quit")
end
private
def send_cmd(cmd, opts = {})
conn = UNIXSocket.new(@sock_path)
conn.puts(JSON.generate(opts.merge("cmd" => cmd)))
resp = JSON.parse(conn.gets)
conn.close
resp
end
end
# Strip ANSI escapes
def strip_ansi(s)
s.gsub(/\e\[[0-9;?]*[A-Za-z]|\e\].*?\a|\r/, "")
end
if __FILE__ == $0
cmd = ARGV.empty? ? ["bash", "--norc", "--noprofile"] : ARGV
name = File.basename(cmd.first)
server = PTYServer.new(*cmd, name: name)
server.serve
end
name description
control-interactive-tool
This skill should be used when the user asks to "debug with lldb", "attach gdb", "run an interactive CLI", "control lldb/gdb", or needs to drive an interactive terminal tool (debugger, REPL, etc.) programmatically from Claude Code.

Control Interactive Tool

Drive interactive CLI tools (lldb, gdb, python, bash, etc.) via a background PTY server over a Unix socket.

Architecture

A Ruby PTY server spawns the tool in a pseudo-terminal and exposes it over a Unix domain socket with a JSON request/response protocol. This lets Claude send commands and read output across multiple conversation turns without the process dying.

  • Server: references/pty_server.rb — background process holding the PTY, listening on /tmp/<name>-<pid>.sock
  • Client: PTYClient class in the same file — fire-and-forget JSON requests
  • Output detection: Silence heuristic (settle param) — output is "done" when the process stops writing for N seconds. No prompt matching needed.

Protocol

-> {"cmd": "run", "input": "bt", "settle": 0.3, "timeout": 5}
<- {"output": "...", "alive": true}

-> {"cmd": "drain", "settle": 0.3, "timeout": 5}
<- {"output": "...", "alive": true}

-> {"cmd": "quit"}
<- {"output": "...", "alive": false}

Usage

1. Start the server in background

ruby /path/to/pty_server.rb lldb /path/to/binary &

The first line of stdout is the socket path (e.g. /tmp/lldb-12345.sock).

2. Send commands from a client script

require "/path/to/pty_server.rb"
c = PTYClient.new("/tmp/lldb-12345.sock")

r = c.run("b iseq_to_hir")
puts strip_ansi(r["output"])

r = c.run("run", settle: 3, timeout: 15)
puts strip_ansi(r["output"])

r = c.run("bt", settle: 0.5)
puts strip_ansi(r["output"])

c.run("kill")
c.quit

3. Multiple sessions

Each server gets a unique socket (/tmp/<name>-<pid>.sock), so you can run many in parallel:

sock1 = IO.popen(["ruby", "pty_server.rb", "lldb", bin]).gets.chomp
sock2 = IO.popen(["ruby", "pty_server.rb", "gdb", "-q", bin]).gets.chomp
c1 = PTYClient.new(sock1)
c2 = PTYClient.new(sock2)

Key Parameters

Parameter Default Purpose
settle 0.3s (run), 0.5s (init) How long to wait for silence before returning output
timeout 5s (run), 10s (init) Max wait time
  • Use higher settle (1-3s) for commands that produce output in bursts (e.g. run in lldb)
  • Use higher timeout for slow startup tools

Workflow for ZJIT Debugging

# Start server
ruby pty_server.rb lldb /path/to/build-dev/ruby &

# Client
c = PTYClient.new(sock_path)
c.run("settings set -- target.run-args --zjit --zjit-call-threshold=1 script.rb")
c.run("b iseq_to_hir")
c.run("run", settle: 3, timeout: 15)  # hits breakpoint
c.run("bt")                            # backtrace
c.run("frame variable")                # inspect locals
c.run("kill")
c.quit

Dependencies

Ruby stdlib only: pty, io/wait, socket, json. No gems needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment