Zig-based PTY library. Dual-use: standalone Zig package and Node.js NAPI addon.
- Smallest build (19KB ReleaseSmall vs node-pty's ~500KB+ with C++ runtime)
- Pure Zig PTY library with no NAPI dependency (
lib.zig) - Thin NAPI wrapper layer for Node.js (
pty.zig+pty_unix.zig+win/napi.zig+root.zig) - Raw NAPI — no third-party Zig NAPI bindings, no node-gyp
- Cross-platform (Linux + macOS + Windows)
- Statically linked via Zig
zigpty/
├── build.zig # Zig build: exposes "zigpty" module + NAPI shared libs
├── build.zig.zon # Zig package metadata (min Zig 0.15.1)
├── build.config.ts # obuild config (bundle src/ → dist/)
├── zig/ # Zig sources (two layers)
│ ├── lib.zig # Pure Zig PTY library — platform dispatcher + shared code
│ ├── root.zig # NAPI module entry: exports platform-specific functions
│ ├── napi.zig # Raw NAPI bindings (~240 lines, extern declarations)
│ ├── pty.zig # NAPI shared helpers + platform dispatch (re-exports)
│ ├── pty_unix.zig # NAPI↔lib.zig bridge for Unix (fork, open, resize, process)
│ ├── termios.zig # Default terminal config (Linux + macOS)
│ ├── pty_linux.zig # Linux-specific: execvpe, ptsname_r, /proc, close_range
│ ├── pty_darwin.zig # macOS-specific: execvp+environ, sysctl, FD close loop
│ ├── pty_windows.zig # Windows-specific: ConPTY (CreatePseudoConsole + pipes)
│ ├── errno_shim.c # Android errno compat (__errno_location → __errno)
│ └── win/
│ ├── napi.zig # NAPI↔lib.zig bridge for Windows (spawn, write, resize, kill, close)
│ └── node_api.def # NAPI import definitions (→ .lib via zig dlltool)
├── src/ # TypeScript wrapper + tests
│ ├── index.ts # Public API: spawn(), open() — platform dispatch
│ ├── napi.ts # Native module loader (platform-aware, INativeUnix/INativeWindows)
│ ├── terminal.ts # Terminal class (Bun-compatible) + TerminalOptions type
│ ├── pty/ # PTY class hierarchy
│ │ ├── _base.ts # BasePty abstract class — shared state, events, waitFor, buildEnvPairs
│ │ ├── types.ts # IPty, IPtyOptions, IDisposable, IEvent interfaces
│ │ ├── unix.ts # UnixPty: tty.ReadStream + async fs.write
│ │ ├── windows.ts # WindowsPty: native callbacks + deferred init
│ │ └── pipe.ts # PipePty: pure-TS fallback when native bindings unavailable
│ ├── spawn.test.ts # E2E tests (platform-conditional)
│ ├── pipe.test.ts # PipePty fallback tests
│ └── terminal.test.ts # Terminal class tests
├── dist/ # Built output (obuild → .mjs + .d.mts)
├── prebuilds/ # Zig build output (8 binaries, see below)
├── scripts/
│ └── cross-platform.sh # Docker-based cross-platform smoke tests
└── .github/workflows/
└── ci.yml # PR/push: build + test (Linux, macOS, Windows)
IPty (interface, src/pty/types.ts)
└── BasePty (abstract, src/pty/_base.ts)
├── UnixPty (src/pty/unix.ts) — native PTY via Zig NAPI
├── WindowsPty (src/pty/windows.ts) — native ConPTY via Zig NAPI
└── PipePty (src/pty/pipe.ts) — pure-TS fallback (child_process pipes)
Terminal (standalone, src/terminal.ts) — Bun-compatible callbacks, AsyncDisposable
BasePty contains shared logic:
- State:
_dataListeners,_exitListeners,_closed,_exitCode,_exitedpromise,_terminal - Event getters:
onData,onExit,exited,exitCode waitFor(pattern, { timeout? })— waits for string in output, hooks into bothonDataand Terminal data paths_handleExit(info)— common exit callback (set closed, fire listeners, resolve promise)buildEnvPairs(env, termName?, sanitizeKeys?)— shared env builder
UnixPty adds: fd management, tty.ReadStream, async write queue with EAGAIN retry, flow control, process via native, signal-based kill().
WindowsPty adds: ConPTY handle, deferred calls until ready, napi_threadsafe_function data/exit callbacks.
PipePty (fallback, no native dependency): Spawns child via child_process.spawn with stdio: ["pipe", "pipe", "pipe"]. Emulates terminal behavior in userspace:
- Signal character translation (
^C→SIGINT,^Z→SIGTSTP,^\→SIGQUIT,^D→EOF) - Canonical mode with echo (line buffering, backspace,
^Wword erase,^Uline kill,^Rreprint) - Raw mode (
setRawMode()/setCanonicalMode()) — disables echo and line buffering - XON/XOFF flow control interception when
handleFlowControlis enabled - Force-color env hints (
FORCE_COLOR=1,COLORTERM=truecolor) auto-set SIGWINCHsent onresize()as best-effort hint- Foreground process tracking via
/proc/<pid>/stat→/proc/<pgrp>/cmdline(Linux only) - Merges stdout + stderr into single data stream (matching real PTY behavior)
Native loading (napi.ts): loadNative() returns null on failure instead of throwing. hasNative boolean exported for runtime detection. spawn() in index.ts routes to PipePty when hasNative === false.
The Zig code is split into two layers:
-
Pure Zig library (
lib.zig+pty_linux.zig+pty_darwin.zig+pty_windows.zig+termios.zig) — No NAPI dependency.lib.zigdispatches to platform-specific modules viabuiltin.os.tag. Unix exposesforkPty,openPty,resize,getProcessName,waitForExit. Windows exposesspawnConPty,readOutput,writeInput,resizeConsole,waitForExit,killProcess,closePty. Can be imported by any Zig project via@import("zigpty"). -
NAPI wrapper (
root.zig+pty.zig+pty_unix.zig+win/napi.zig+napi.zig) — Thin bridge that parses NAPI arguments and callslib.zig.pty.zigcontains shared helpers and re-exports platform-specific symbols.pty_unix.zighandles Unix (fork, open, resize, process).win/napi.zighandles Windows ConPTY (spawn, write, resize, kill, close). Registers different exports on Windows vs Unix. Managesnapi_threadsafe_functionfor exit monitoring and (on Windows) data streaming.
# Debug build (native + cross targets — all 8 platforms)
zig build
# Release build (native + cross targets)
zig build --release
# Build single target only
zig build -Dtarget=aarch64-linux-musl
zig build -Dtarget=x86_64-windows
# Build TS (via obuild, also runs zig build --release)
bun run build
# Run tests
bun run test
# Cross-platform smoke tests (Docker, Linux only)
bash scripts/cross-platform.shzig build produces 8 binaries by default (native + cross_targets in build.zig):
| File | Target |
|---|---|
zigpty.linux-x64.node |
x64 glibc (Debian/Ubuntu/Fedora) |
zigpty.linux-x64-musl.node |
x64 musl (Alpine) |
zigpty.linux-arm64.node |
arm64 glibc (Ubuntu on Graviton/RPi) |
zigpty.linux-arm64-musl.node |
arm64 musl (Alpine arm64 + Android) |
zigpty.darwin-arm64.node |
macOS arm64 (Apple Silicon) |
zigpty.darwin-x64.node |
macOS x64 (Intel) |
zigpty.win32-x64.node |
Windows x64 |
zigpty.win32-arm64.node |
Windows arm64 |
The native loader (napi.ts) tries glibc first, falls back to musl on Linux. On Android (platform() === "android"), maps to linux and loads the musl binary. Musl builds include a weak errno shim for Android/Bionic compatibility. Musl builds use direct linux.syscall3(.close_range, ...) to avoid libc symbol dependencies. Windows builds don't link libc.
The pure Zig library (lib.zig) is exposed as the "zigpty" module in build.zig:
| Function | Signature | Description |
|---|---|---|
forkPty |
(ForkOptions) !ForkResult |
Fork process with PTY (forkpty + signal handling + execvpe) |
openPty |
(cols, rows) !OpenResult |
Open bare PTY pair |
resize |
(fd, cols, rows, x_pixel, y_pixel) !void |
Resize PTY (ioctl TIOCSWINSZ) |
getProcessName |
(fd, buf) ?[]const u8 |
Foreground process name via /proc |
getStats |
(pid, allocator, cwd_buf) ?Stats |
Aggregate rss + cpu across the leader process and every transitive descendant (BFS by ppid). Linux: snapshots /proc/<pid>/stat rows once, BFS from leader by linear ppid scan. macOS: proc_listpids(PROC_ALL_PIDS) + proc_pidinfo(PROC_PIDT_SHORTBSDINFO) for the parent walk, PROC_PIDTASKINFO for marked pids. Returns leader cwd + totals + per-child array (caller must stats.deinit(allocator)). |
waitForExit |
(pid) ExitInfo |
Blocking wait for child exit (call from background thread) |
Types: ForkOptions, ForkResult, OpenResult, ExitInfo, Stats, ChildStats, PtyError, Fd, Pid
Available via lib.win (re-exports pty_windows.zig):
| Function | Signature | Description |
|---|---|---|
createConPty |
(cols, rows) !ConPtySetup |
Phase 1: create pipes + pseudo console |
startProcess |
(hpc, cmd_line, env_block, cwd) !{process, pid} |
Phase 2: spawn process in ConPTY |
spawnConPty |
(cmd_line, env_block, cwd, cols, rows) !SpawnResult |
Convenience: createConPty + startProcess |
readOutput |
(conout, buf) usize |
Read from output pipe (blocking) |
writeInput |
(conin, data) !void |
Write to input pipe |
resizeConsole |
(hpc, cols, rows) !void |
Resize pseudo console |
waitForExit |
(process) ExitInfo |
Wait for process exit (blocking) |
killProcess |
(process, exit_code) void |
Terminate process |
closePty |
(result) void |
Close all ConPTY handles |
getStats |
(process, pid, allocator) ?Stats |
Aggregate rss + cpu across the shell process and its descendant tree via CreateToolhelp32Snapshot. Per-descendant data via OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + K32GetProcessMemoryInfo + GetProcessTimes. No cwd. |
Types: SpawnResult, ConPtySetup, ExitInfo, Stats, ChildStats, ConPtyError, HPCON, HANDLE
| Export | Signature | Implementation |
|---|---|---|
fork |
(file, args[], env[], cwd, cols, rows, uid, gid, utf8, cb) → {fd, pid, pty} |
lib.forkPty() + thread waitForExit() |
open |
(cols, rows) → {master, slave, pty} |
lib.openPty() |
resize |
(fd, cols, rows) → void |
lib.resize() |
process |
(fd) → string |
lib.getProcessName() |
stats |
(pid) → {pid, cwd, rssBytes, cpuUser, cpuSys, count, children[]} | undefined |
lib.getStats() (aggregates leader + ppid descendant tree) |
| Export | Signature | Implementation |
|---|---|---|
spawn |
(file, args[], env[], cwd, cols, rows, onData, onExit) → {pid, handle} |
win.spawnConPty() + read thread + exit thread |
write |
(handle, data) → void |
win.writeInput() |
resize |
(handle, cols, rows) → void |
win.resizeConsole() |
kill |
(handle) → void |
win.killProcess() |
close |
(handle) → void |
win.closePty() |
stats |
(handle) → {pid, cwd, rssBytes, cpuUser, cpuSys, count, children[]} | undefined |
win.getStats() (shell + descendant tree, cwd always null) |
Windows uses napi_external to wrap the WinConPtyContext handle. Data flows from a Zig read thread to JS via napi_threadsafe_function (onData callback).
import { spawn } from "zigpty";
// Works on all platforms — spawn() dispatches to UnixPty or WindowsPty
const pty = spawn("/bin/bash", [], { cols: 120, rows: 40 });
// Windows: spawn("cmd.exe", [], { cols: 120, rows: 40 })
pty.onData((data) => process.stdout.write(data));
pty.onExit(({ exitCode }) => console.log("exited:", exitCode));
pty.write("echo hello\n");
pty.resize(80, 24);
pty.kill("SIGTERM"); // Windows: kill() terminates the processWait for a specific string to appear in the PTY output. Useful for AI agents driving interactive programs.
const pty = spawn("python3", ["-c", `name = input("name? ")`]);
await pty.waitFor("name?"); // resolves when output contains "name?"
pty.write("zigpty\n");Options: { timeout?: number } (default: 30s). Throws on timeout.
On-demand snapshot of OS-level process info for the PTY. Returns {pid, cwd, rssBytes, cpuUser, cpuSys, count, children} or null. rssBytes/cpuUser/cpuSys are totals aggregated across the leader and every tracked child. count is the total number of processes that were rolled into the totals. children[] contains one entry per non-leader descendant ({pid, name, rssBytes, cpuUser, cpuSys}). CPU times are in microseconds, rssBytes is resident set size.
The aggregation unit is the leader's transitive descendant tree on every platform — BFS from the spawned process pid (e.g. bash) along ppid edges. Catches background jobs, subshells, pipelines, and grandchildren regardless of pgrp/session/job-control juggling. Double-fork daemons (nohup, setsid + intermediate exit) reparent to init/launchd and fall out of the tree — that's intentional, since they explicitly detached.
- Linux: snapshots
/proc/*/statrows once into a list, BFS fromleader_pidby linear-scanning unmarked entries for matching ppid (O(N²) in proc count, fine for typical trees). Aggregates rss (rss_pages * page_size) and cpu (utime/stime ticks) for every marked pid. Leadercwdviareadlink(/proc/<leader_pid>/cwd). - macOS:
proc_listpids(PROC_ALL_PIDS)snapshots every pid;proc_pidinfo(PROC_PIDT_SHORTBSDINFO)(64 bytes per pid) yields ppid + comm for the BFS. For marked pids,proc_pidinfo(PROC_PIDTASKINFO)provides rss + cpu (mach absolute time → microseconds viamach_timebase_info). Leadercwdviaproc_pidinfo(PROC_PIDVNODEPATHINFO). Comm name comes from SHORTBSDINFO, no separateproc_namecall. - Windows:
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)snapshots every running process; transitive descendants of the shell are BFS-marked. Per-descendant rss + cpu viaOpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)+K32GetProcessMemoryInfo+GetProcessTimes.cwdis alwaysnull— reading another process's cwd requiresNtQueryInformationProcess+ remote PEB read, which is fragile across elevation boundaries. - PipePty (fallback): Linux-only. Snapshots
/proc/*/statinto aMap, builds a ppid index, BFS fromchild_pid— same algorithm as native. Returnsnullon other platforms.
No background thread — stats are pulled only when called. Returns null after close, when the process has exited, or when the leader is no longer in the snapshot.
Name field caveats: Unix comm (/proc/<pid>/stat on Linux, pbsi_comm on macOS) truncates to 15 chars; Windows szExeFile allows up to ~31 chars (we truncate to 31). On macOS, Apple-shipped sleep / ls etc. have a g-prefixed comm (e.g. gsleep) when invoked via symlink — that is the kernel's view, not a bug.
spawn() accepts optional terminal: TerminalOptions | Terminal in options for callback-based data (Uint8Array) and Promise-based exit (pty.exited). IPty now has exited: Promise<number> and exitCode: number | null. Terminal class (terminal.ts) can be standalone (new Terminal() opens bare PTY) or passed to spawn() (attaches to fork's fd on Unix, ConPTY handle on Windows). Supports AsyncDisposable.
- Graceful fallback:
loadNative()returnsnullinstead of throwing when native bindings can't load.spawn()auto-routes toPipePty(pure TypeScript,child_process.spawnwith pipes). This prevents hard crashes in containers, sandboxes, CI without prebuilds, or WASM runtimes.open()throws a clear error in fallback mode since bare PTY pairs require kernel support. - Userspace line discipline in PipePty: Canonical mode buffers input line-by-line with echo, backspace,
^W/^U/^Rhandling. Raw mode (setRawMode()) passes bytes through directly. Signal chars (^C/^Z/^\/^D) are intercepted in the write path and translated to OS signals / EOF regardless of mode. - Two-layer architecture: Pure Zig library (
lib.zig) with thin NAPI wrapper. Enables Zig package use without Node.js dependency. - BasePty abstract class: Shared state management, event listeners,
waitFor, and exit handling extracted into_base.ts.UnixPtyandWindowsPtyextend it with platform-specific logic only. - Raw NAPI over tokota/zig-napi: Zero dependency risk.
napi.zigis ~240 lines of pureexterndeclarations. - Pure extern declarations:
forkpty,openpty,waitpid, etc. declared asextern fn— no@cImportfor platform-specific headers. NAPI and Windows kernel32 are also pure Zig externs. - Platform dispatch via
builtin.os.tag:lib.zigimportspty_linux.zig,pty_darwin.zig, orpty_windows.zigat comptime. Unix and Windows APIs are conditionally compiled. - Signal blocking around fork (Unix): Prevents race conditions (matches node-pty behavior).
close_rangesyscall with/proc/self/fdfallback (Linux): Direct syscall (not libc extern) to close leaked FDs — avoids musl symbol issues.- FD close loop (macOS): No
/proc/self/fdorclose_range— closes FDs 3..255. - Process name via sysctl (macOS):
sysctl(KERN_PROC_PID)→kinfo_proc.kp_proc.p_comm(offset 243). execvp+ environ (macOS): Noexecvpe— setsenvironglobal then callsexecvp.- ConPTY with anonymous pipes (Windows):
CreatePseudoConsole+CreateProcessWwithPROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE. I/O via anonymous pipes (simpler than named pipes). STARTF_USESTDHANDLESwith null handles (Windows): Critical for ConPTY — without this flag, the spawned process inherits the parent's real console handles and output bypasses the ConPTY pipe entirely. Setting the flag with nullhStdInput/hStdOutput/hStdErrorforces the process to use the pseudo console for all I/O.- Two-phase ConPTY startup (Windows):
createConPtycreates pipes + pseudo console, then read thread starts draining the output pipe, thenstartProcessspawns the process. This ensures no output is lost for fast-exiting processes (matches node-pty's two-phase approach). - Zig read thread for Windows: Reads ConPTY output pipe in a background thread, forwards data to JS via
napi_threadsafe_function. Avoids pipe-fd ↔ Node.js compatibility issues. - Deferred calls on Windows:
WindowsPtybuffers write/resize calls until first data is received (prevents ConPTY deadlock on startup). - No libc on Windows: Windows builds don't link libc — uses
page_allocatorinstead ofc_allocator. - NAPI import lib via dlltool (Windows):
win/node_api.deflists NAPI symbols imported fromnode.exe. Build generates import.libat build time viazig dlltool— no checked-in binaries. - ConPTY flush on exit (Windows):
ClosePseudoConsolemust be called after process exit to flush remaining output. Input pipe is closed first, then pseudo console, while read thread drains output concurrently to avoid deadlock. tty.ReadStreamfor reading PTY output on Unix (notnet.Socket— PTY fds are TTY type).- Async
fs.writewith EAGAIN retry for writing on Unix (non-blocking write queue withsetImmediatebackoff). - Android errno shim (
zig/errno_shim.c): Android's Bionic libc uses__errno()instead of musl's__errno_location(). All musl builds link a tiny C shim with weak symbols —__errno_location(weak defined) forwards to__errno(weak undefined). On musl Linux, musl's strong__errno_locationoverrides the shim. On Android/Bionic, the shim activates and__errnoresolves from Bionic's libc. No separate Android binary needed. - Platform-aware native loading:
napi.tsresolveszigpty.<os>-<arch>.nodewith glibc→musl fallback on Linux. On Android (platform() === "android"), maps tolinuxand loads the musl binary. ptsname_r/ttyname_rin lib.zig (thread-safe) vsptsname/ttynamein old pty.zig.- Raw
execvesyscall + PATH search (Linux):execChilduses rawlinux.execveinstead of musl'sexecvpe. OnEACCES(noexec mount), falls back toexecveLinkerFallbackwhich reads the ELFPT_INTERPheader and re-execs via the dynamic linker (e.g./system/bin/linker64). This is the same technique Termux'slibtermux-exec.souses. Zero overhead on normal Linux — the fallback is only triggered onEACCES. rawExitviaexit_groupsyscall (Linux) /_exit(macOS): Afterfork(), the child must not call musl'sexit()which runs atexit handlers registered by Node.js/V8 — on Android these expect Bionic's libc state, causing hangs or zombies.exit_groupsyscall (Linux) or_exit(macOS) terminates immediately without atexit handlers.
Root cause: /data/data is mounted as tmpfs with noexec. Termux binaries live under /data/data/com.termux/files/usr/bin/ — the kernel enforces the parent mount's noexec flag on execve(). Termux normally works around this via LD_PRELOAD=libtermux-exec.so, which intercepts execve and rewrites it to invoke the ELF dynamic linker (/system/bin/linker64) directly. VS Code's ptyHost process is forked without LD_PRELOAD, so libtermux-exec.so is absent and native execve fails.
Fix: execveLinkerFallback reads the ELF PT_INTERP header from the target binary to get the dynamic linker path, then calls execve(linker_path, [argv0, binary_path, ...rest], envp). Only triggered on EACCES, so normal Linux is unaffected.
Root cause: After fork(), calling musl's exit() (via std.process.exit) runs atexit handlers registered by Node.js/V8, which expect Bionic's libc state — this can hang or crash, leaving zombie processes.
Fix: rawExit calls the exit_group syscall directly on Linux (or _exit on macOS), bypassing all atexit handlers.
Zig cross-compiles for aarch64-linux-musl — same target for both Android and regular Linux ARM64. The EACCES check is the runtime gate: it only fires on noexec mounts (Android), so no comptime or explicit Android detection is needed.
Critical lessons learned from debugging the Windows ConPTY implementation. These are subtle issues not well-documented by Microsoft that can cause silent data loss or broken I/O.
Symptom: ConPTY appears to work — init VT sequences (mode changes, clear screen, title) arrive through the output pipe — but ALL actual process output (echo, command output, etc.) goes to the real console instead.
Root cause: Without STARTF_USESTDHANDLES in STARTUPINFO.dwFlags, CreateProcessW inherits the parent's real console handles. The pseudo console is attached via PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, but the std handles still point to the real console. Process output writes to the real console directly, bypassing the ConPTY pipe.
Fix: Set si.StartupInfo.dwFlags = STARTF_USESTDHANDLES with null hStdInput/hStdOutput/hStdError. This forces the process to create its console handles through the pseudo console. node-pty does the same — see conpty.cc → PtyConnect.
Debugging note: This is extremely hard to diagnose because ConPTY's own VT init sequences DO arrive through the pipe (they're generated by ConPTY itself, not by the process). The process output silently goes elsewhere. On CI runners without a visible console, the output just vanishes. Adding console.log in the data callback shows init chunks arriving, making it look like the pipe works — but only ConPTY-generated data flows through it.
Symptom: close() hangs indefinitely on Windows.
Root cause: ClosePseudoConsole blocks until the output pipe is fully drained. If the read thread delivers data via napi_threadsafe_function, the tsfn callback must fire on the JS thread. If the JS thread is blocked by ClosePseudoConsole, the callback can never run → deadlock.
Fix: Only call ClosePseudoConsole from the exit monitor thread (background), never from JS-thread functions like winCloseImpl. The JS-side close() should just killProcess — the exit monitor handles the rest.
Correct order after waitForExit returns:
- Close input pipe (
closeConin) — no more input after process exit - Call
ClosePseudoConsole— flushes remaining VT output, closes output pipe write end - Join read thread — it gets EOF from step 2 and exits
- Close remaining handles (
closePty) - Fire exit callback — all data has been delivered
Why this order: ClosePseudoConsole blocks until the output pipe is drained. The read thread must be running concurrently to drain it (step 3 after step 2). Closing conin first (step 1) prevents the ConPTY from reading stale input during shutdown.
The inner pipe handles (pipe_in_read, pipe_out_write) passed to CreatePseudoConsole ARE duplicated internally. It is safe to close them after CreatePseudoConsole returns. However, for clarity and to match node-pty's pattern, we close them after startProcess/CreateProcessW.
| Aspect | node-pty | zigpty |
|---|---|---|
| Pipe type | Named pipes (128KB buffers) | Anonymous pipes (default ~4KB) |
| Output reading | Worker thread → IPC → Socket → JS | Zig read thread → napi_threadsafe_function → JS |
| Process spawn | Two-phase: startProcess (create ConPTY) → connect (ConnectNamedPipe + CreateProcessW) |
Two-phase: createConPty → startProcess (CreateProcessW) |
| Exit handling | Never calls ClosePseudoConsole on normal exit; 1000ms flush timer then socket close |
Calls ClosePseudoConsole from exit monitor thread after process exit |
| Kill handling | ClosePseudoConsole + TerminateProcess (via PtyKill) |
TerminateProcess from JS; ClosePseudoConsole from exit monitor |
| Native bindings | C++ with node-addon-api | Pure Zig raw NAPI externs (~240 lines) |