One-shot helper that opens a PTY, forks a child, execvp's the given argv with the slave as stdin/stdout/stderr, and hands the master fd back to the caller over a unix socket via SCM_RIGHTS. Wire contract is stdin JSON → stdout JSON + fd transfer per D-005; socket fd defaults to 3 with PTYC_SOCK_FD override for language runtimes whose subprocess machinery shuffles the low fd numbers. Minimal JSON parser (no deps) scoped to the exact accepted shape. Exec-failure pipe (CLOEXEC) reports child-side errors back without zombies. Window size applied via TIOCSWINSZ before fork; child becomes session leader + makes the slave its controlling TTY. Root Makefile PTYX_PRESENT typo fixed → PTYC_PRESENT, and a ptyc-test target added alongside ptyc-build / ptyc-clean. Eight smoke tests pass (happy path, env replacement, cwd, window size, bad argv, type errors, exec failure, unknown keys). Co-Authored-By: Claude <noreply@anthropic.com>
ptyc
Small POSIX helper that spawns a child process under a PTY and hands the master fd back to its caller. Language-agnostic; usable from any program that can fork a subprocess and receive a file descriptor over a unix socket.
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
tmux wrappers, LSP servers, debug adapters). See
D-005
for the architectural rationale; ptyc is a peer of
pql, not a clide subsystem.
Build
make # produces bin/ptyc
make test # runs test_ptyc.sh against the built binary
make clean
No third-party dependencies. CC, CFLAGS, and LDFLAGS are
overrideable in the usual way.
Wire contract
ptyc is a one-shot helper. The caller:
- Creates a
socketpair(AF_UNIX, SOCK_STREAM, 0). - Launches
ptycas a subprocess, passing one end of the socket to the child as file descriptor 3 (the default) or whatever fd is given in thePTYC_SOCK_FDenvironment variable. The other end of the socket stays with the caller. The env-var override exists because some language runtimes (Python'ssubprocesswithstdout=PIPE, for example) shuffle their own pipe fds through the low numbers and it's cheaper for the caller to pick a higher fd than to dup2 it down. - Writes the request as JSON on stdin and closes stdin (EOF signals end of request).
- Receives the master PTY fd over the socket via
SCM_RIGHTSancillary data (with a single-byte'x'payload so the receiver knows when torecvmsg). - Reads the success response from stdout (single line of JSON) and
reaps the exited
ptycprocess.
Request
JSON object on stdin. All fields optional except argv.
{
"argv": ["bash", "-l"],
"cwd": "/home/me/work",
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
"cols": 80,
"rows": 24
}
argv— required, non-empty array of strings.argv[0]is resolved viaPATH.cwd— optional. If omitted, the child inherits ptyc's cwd.env— optional object. If present, the child's environment is replaced with exactly the keys given (ptyc doesclearenv()and thenputenvper entry). If absent, the child inherits ptyc's environment. This is a deliberate choice: the daemon is expected to build the env it wants, not rely on a merge.cols,rows— optional. Default80×24. Applied viaTIOCSWINSZbefore fork.
Success response (stdout)
{"ok":true,"pid":12345}
One line, trailing newline. The master PTY fd is already on the socket
by the time stdout is written. pid is the spawned child's PID — the
caller is responsible for waitpid'ing it when appropriate.
Error response (stderr)
{"ok":false,"error":"exec: No such file or directory","errno":2}
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success — fd sent, success response on stdout. |
1 |
Bad request — JSON parse error, missing argv, bad field values. |
2 |
Syscall failed — fork, exec, PTY open, sendmsg, etc. Check errno in the response. |
Limits
Compile-time caps, deliberately small:
MAX_ARGV= 64 entriesMAX_ENV= 256 entriesMAX_INPUT= 64 KiB request size
These are far above what any reasonable pane invocation needs; if you
hit them you're holding ptyc wrong. Edit the #defines in ptyc.c and
rebuild.
Security notes
- The JSON parser is scoped to the shape above. It rejects anything
else. Strings support the standard
\"\\\/\b\f\n\r\tescapes and ASCII-range\uXXXX. Non-ASCII Unicode escapes (and surrogate pairs) are rejected — the daemon is expected to emit raw UTF-8 bytes. - Input is trusted (daemon is local, same user). ptyc does not sanitise
argv or env beyond format-level checks — if the daemon asks ptyc to
exec
rm, ptyc execsrm. - ptyc does not
setuidorsetgid. It runs as the invoking user.
Session persistence
ptyc is stateless and one-shot. Session persistence (survive app
restart) is the caller's concern. Clide achieves it by spawning
ptyc with tmux new-session -A -s <name> -- <cmd> for Claude panes;
tmux handles the persistence layer and ptyc just spawns tmux. See
D-041 (Claude panes — one primary per repo, tmux-backed).