# Introduction zeropty is a **session engine** for AI agents. It starts programs such as coding agents, reads their screens, sends them keys and tells you when they finish. It takes the same commands as [tmux](https://github.com/tmux/tmux), but it uses no PTY (pseudo-terminal) at all, which is where the name comes from. zeropty is written in [Rust](https://www.rust-lang.org) and runs on macOS and Linux (x86_64). You can use it as a command-line tool, talk to its socket from any language, or put the engine inside your own program. ## How does it work? ### Sessions and the server A **session** is one program with one screen and its scrollback. There are no windows or panes. The `zeropty` CLI sends commands to a **server** that owns every session, and the first `new-session` starts that server for you, as tmux does. Commands name a session with a **target** (`-t =name:`), and the name must match exactly. For details, see [Sessions and the server](/guide/concepts/sessions). ### How a session gets a terminal tmux gives every window a PTY, and macOS allows only 511 of them by default (999 at most). zeropty connects each session through a socketpair instead, so there is no such limit. Programs that check for a terminal, such as vim or [Claude Code](https://docs.anthropic.com/en/docs/claude-code), run through `zeropty-tty`, which injects a small library that answers terminal calls. The engine itself does what the PTY did: Enter becomes a newline, `C-c` becomes a signal, and typed text is echoed. For details, see [How a session gets a terminal](/guide/concepts/terminal). ### Screens, keys and waiting The engine parses each session's output into its own screen structure of 6 bytes per cell. Quiet screens are folded into about 1KB. `send-keys` and `capture-pane` work as in tmux. `wait-pane` answers the moment text appears or output pauses, and `grep-panes` searches every screen at once, so nothing has to poll. For details, see [Screens, keys and waiting](/guide/concepts/screens). ### Four ways to drive zeropty Every command has the same form everywhere: a list of arguments such as `["send-keys", "-t", "=work:", "Enter"]`. You can send it from the CLI, as one line of JSON on the server socket, or as a function call into an embedded engine (C ABI, Node.js module or Rust crate). Plugins listen to session events and add their own commands. For details, see [Four ways to drive zeropty](/guide/concepts/interfaces). ## Why zeropty? Programs that run many coding agents usually start them in tmux sessions and call `capture-pane` over and over to read their screens. With hundreds of sessions, that approach runs into the problems below. zeropty was built for agent hosts from the start. | What an agent host needs | tmux | zeropty | |---|---|---| | Hundreds to thousands of sessions | PTY limit of 511 (999 at most) | 1000 verified, no PTY limit | | Reading a screen, sending keys | Runs tmux for every command (`send-keys` 9.03~9.56ms from Node) | One function call (`send-keys` 0.06~0.07ms embedded) | | Knowing that an agent finished | `capture-pane` in a loop (p50 86~96ms late at a 200ms poll) | `wait-pane`: p50 1ms, no polling | | Which session is waiting for input | `capture-pane` per session | `grep-panes`: 1000 sessions in 16~32ms | | Upgrading the server | Restarting the server ends the sessions | `restart-server` takes over the running sessions | | Putting it inside your program | Not possible | C ABI, Node module, Rust crate | - **The PTY limit.** tmux needs one PTY per session. zeropty uses none, and 1000 sessions replaying real claude work were measured at 4.65MB of memory and 6.69~7.10% CPU. tmux cannot start 1000. - **A process per command.** Running tmux for each command costs a process start every time. zeropty answers over one socket connection (0.24~0.26ms per `send-keys` from Node) or as a function call (0.06~0.07ms). - **Polling to know an agent finished.** With 200 sessions, 200ms polling noticed a finished task p50 86~96ms late. `wait-pane` noticed it in 1ms. - **Finding who waits for input.** Reading 1000 screens one by one through the CLI took 4.2~4.3 seconds. `grep-panes` took 16~32ms. - **Restarts end sessions.** `restart-server` hands every running session to a new server. The programs keep running and keep their pids. - **Embedding.** With the engine inside your process there is no server process, and a command is a function call. The cost is lower too. With 400 sessions replaying recordings of real claude work, measured against tmux 3.7c on an Apple M1, zeropty used 3.51~3.84% CPU against 54.30~54.91% (14.1~15.6x less), and 2.66~2.70MB of server memory against 32.4~32.5MB (12.0~12.2x less). For every measurement and how it was taken, see [Benchmarks](/guide/benchmarks). zeropty does not replace tmux as a terminal multiplexer for people. It has no window splits, status line, copy mode or configuration file. When you want to look at a session, `zeropty attach` connects your terminal to it. ## Next steps - [Get started](/guide/getting-started): build zeropty, run a session, wait for its output and read the screen. - [Install and platform support](/guide/install): release archives, what each file is for, and supported Macs. - [Sessions and the server](/guide/concepts/sessions): the first concept page. - [Coming from tmux](/guide/from-tmux): what changes in your existing scripts. --- # 소개 zeropty는 AI 에이전트를 위한 **세션 엔진**이에요. 코딩 에이전트 같은 프로그램을 띄우고, 화면을 읽고, 키를 보내고, 끝나면 알려 줘요. [tmux](https://github.com/tmux/tmux)와 같은 명령을 받지만 PTY(가상 터미널)는 하나도 쓰지 않아요. 이름도 여기서 왔어요. zeropty는 [Rust](https://www.rust-lang.org)로 만들었고 macOS와 Linux(x86_64)에서 돌아요. 명령줄 도구로 쓰거나, 어떤 언어에서든 소켓으로 말하거나, 엔진을 내 프로그램 안에 넣을 수 있어요. ## 어떻게 동작하나요? ### 세션과 서버 **세션**은 프로그램 하나와 그 화면 하나, 스크롤백이에요. 창이나 창 분할은 없어요. `zeropty` CLI는 모든 세션을 가진 **서버**에 명령을 보내고, 첫 `new-session`이 tmux처럼 서버를 띄워 줘요. 명령은 **타깃**(`-t =name:`)으로 세션을 가리키고, 이름은 정확히 일치해야 해요. 자세한 내용은 [세션과 서버](/ko/guide/concepts/sessions)를 보세요. ### 세션이 터미널을 얻는 방법 tmux는 창마다 PTY를 하나씩 주는데, macOS는 기본으로 511개(최대 999개)까지만 허용해요. zeropty는 세션마다 socketpair로 연결해서 이런 한도가 없어요. vim이나 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)처럼 터미널인지 확인하는 프로그램은 `zeropty-tty`로 띄워요. `zeropty-tty`는 터미널 호출에 답하는 작은 라이브러리를 넣어 줘요. PTY가 하던 일은 엔진이 직접 해요. Enter는 줄바꿈이 되고, `C-c`는 시그널이 되고, 입력한 글자는 에코돼요. 자세한 내용은 [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal)을 보세요. ### 화면, 키, 기다리기 엔진은 세션마다 출력을 셀 하나에 6바이트인 자체 화면 구조로 해석해요. 조용한 화면은 약 1KB로 접어 둬요. `send-keys`와 `capture-pane`은 tmux와 똑같이 동작해요. `wait-pane`은 글자가 나타나거나 출력이 멈추는 순간 답하고, `grep-panes`는 모든 화면을 한 번에 찾아서 폴링할 일이 없어요. 자세한 내용은 [화면, 키, 기다리기](/ko/guide/concepts/screens)를 보세요. ### zeropty를 다루는 네 가지 방법 명령은 어디서나 같은 형태예요. `["send-keys", "-t", "=work:", "Enter"]` 같은 인자 목록이에요. CLI에서 보내도 되고, 서버 소켓에 JSON 한 줄로 보내도 되고, 임베디드 엔진의 함수 호출(C ABI, Node.js 모듈, Rust crate)로 보내도 돼요. 플러그인은 세션 이벤트를 듣고 자기 명령을 더해요. 자세한 내용은 [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces)을 보세요. ## 왜 zeropty 인가요? 코딩 에이전트를 여러 개 돌리는 프로그램은 보통 tmux 세션으로 띄우고 `capture-pane`을 반복해서 화면을 읽어요. 세션이 수백 개가 되면 이 방식은 아래 문제에 부딪혀요. zeropty는 처음부터 에이전트 호스트를 위해 만들었어요. | 에이전트 호스트에게 필요한 것 | tmux | zeropty | |---|---|---| | 세션 수백~수천 개 | PTY 한도 511개 (최대 999) | 1000개 확인, PTY 한도 없음 | | 화면 읽기 · 키 보내기 | 명령마다 tmux 실행 (Node에서 `send-keys` 9.03~9.56ms) | 함수 호출 한 번 (임베디드 `send-keys` 0.06~0.07ms) | | 에이전트가 끝났는지 알기 | `capture-pane` 반복 (200ms 폴링이면 p50 86~96ms 늦게) | `wait-pane`: p50 1ms, 폴링 없음 | | 어느 세션이 입력을 기다리나 | 세션마다 `capture-pane` | `grep-panes`: 1000개를 16~32ms에 | | 서버 업그레이드 | 서버를 다시 띄우면 세션이 끝나요 | `restart-server`가 실행 중인 세션을 넘겨받아요 | | 내 프로그램 안에 넣기 | 안 돼요 | C ABI, Node 모듈, Rust crate | - **PTY 한도.** tmux는 세션마다 PTY가 하나 필요해요. zeropty는 하나도 쓰지 않고, 실제 claude 작업을 재생하는 세션 1000개에서 메모리 4.65MB, CPU 6.69~7.10%로 쟀어요. tmux는 1000개를 띄우지 못해요. - **명령마다 프로세스.** 명령마다 tmux를 실행하면 매번 프로세스 시작 비용이 들어요. zeropty는 소켓 연결 하나로(Node에서 `send-keys` 한 번에 0.24~0.26ms) 또는 함수 호출로(0.06~0.07ms) 답해요. - **끝났는지 알려면 폴링.** 세션 200개에서 200ms 폴링은 끝난 작업을 p50 86~96ms 늦게 알아챘어요. `wait-pane`은 1ms에 알아챘어요. - **누가 입력을 기다리는지 찾기.** CLI로 화면 1000개를 하나씩 읽으면 4.2~4.3초 걸렸어요. `grep-panes`는 16~32ms였어요. - **재시작하면 세션이 끝나요.** `restart-server`는 실행 중인 세션을 모두 새 서버에 넘겨요. 프로그램은 계속 돌고 pid도 그대로예요. - **임베딩.** 엔진을 내 프로세스 안에 넣으면 서버 프로세스가 없고, 명령은 함수 호출이에요. 비용도 적어요. 실제 claude 작업 녹화를 재생하는 세션 400개로 Apple M1에서 tmux 3.7c와 비교하면, zeropty는 CPU 3.51~3.84%(tmux 54.30~54.91%, 14.1~15.6배 적음), 서버 메모리 2.66~2.70MB(tmux 32.4~32.5MB, 12.0~12.2배 적음)를 썼어요. 모든 측정값과 측정 방법은 [벤치마크](/ko/guide/benchmarks)를 보세요. zeropty는 사람이 쓰는 터미널 멀티플렉서로서 tmux를 대신하지 않아요. 창 분할, 상태 표시줄, 복사 모드, 설정 파일이 없어요. 세션을 들여다보고 싶으면 `zeropty attach`로 내 터미널을 붙여요. ## 다음 단계 - [시작하기](/ko/guide/getting-started): zeropty를 빌드하고, 세션을 띄우고, 출력을 기다린 뒤 화면을 읽어요. - [설치와 지원 플랫폼](/ko/guide/install): 릴리스 묶음, 파일마다의 용도, 지원하는 맥. - [세션과 서버](/ko/guide/concepts/sessions): 첫 번째 개념 페이지. - [tmux에서 넘어오기](/ko/guide/from-tmux): 기존 스크립트에서 무엇이 달라지는지. --- # Get started By the end of this guide, you have zeropty installed, a session running a shell, and the screen of that session read back after waiting for its output. The whole flow takes five commands and about a second. ## Before you begin - A Mac (macOS 11 or later, Apple silicon or Intel) or Linux (x86_64, glibc 2.29 or later). For details, see [Install and platform support](/guide/install). - One of these: - **The release archive.** You need only `curl` and `shasum`, which come with macOS. No Rust is needed. - **A build from source (macOS).** You need [Rust](https://rustup.rs) (stable) and the Xcode Command Line Tools (`xcode-select --install`). - You do not need claude or any other agent. This guide runs `sh`. ## 1. Install Pick one of the two ways below. ### Option A: download a release Each [release](https://github.com/CreatiCoding/zeropty/releases) since 0.1.3 carries one archive for every Mac and its `SHA256SUMS`. Download both, check the hash, then unpack: ```sh [Terminal] v=0.1.6 # or the latest: v=$(curl -fsSLo /dev/null -w '%{url_effective}' https://github.com/CreatiCoding/zeropty/releases/latest | sed 's|.*/v||') curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/zeropty-$v-darwin-universal.tar.gz" curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/SHA256SUMS" shasum -a 256 -c --ignore-missing SHA256SUMS # zeropty-0.1.6-darwin-universal.tar.gz: OK tar -xzf "zeropty-$v-darwin-universal.tar.gz" export PATH="$PWD/zeropty-$v-darwin-universal:$PATH" # add it to ~/.zshrc to keep it zeropty -V ``` `v` holds the version. Replace `0.1.6` with another version to pin it. The last command prints the version, for example: ```text zeropty 0.1.6 ``` ::: warning Downloaded with a browser? The files are signed ad hoc, not notarized. `curl` adds no quarantine mark, so the steps above run as they are. A browser download is marked, and macOS then refuses `zeropty` with a dialog. Clear the mark before the first run: `xattr -dr com.apple.quarantine zeropty-$v-darwin-universal`. ::: On Linux (x86_64), download `zeropty-$v-linux-x86_64.tar.gz` instead, check it with `sha256sum -c --ignore-missing SHA256SUMS`, and put `zeropty-$v-linux-x86_64` on your `PATH`. For the full commands, see [Install and platform support](/guide/install#install-methods). ### Option B: build from source Building gives you everything, including the embedding libraries and the bash builtin: ```sh [Terminal] git clone https://github.com/CreatiCoding/zeropty && cd zeropty ./build.sh # puts zeropty and the embedding libraries in dist/ export PATH="$PWD/dist:$PATH" ``` The first `./build.sh` took 23~27 seconds on an Apple M1 (8 cores), from a clean clone with an empty cargo cache. ## 2. Start a session Start a session named `demo` that runs `sh` on a screen of 80 columns and 6 rows: ```sh [Terminal] zeropty new-session -d -s demo -x 80 -y 6 sh # starts the server if none is running ``` `-d` keeps your terminal free, and `-s demo` names the session. No server was running, so this command also starts one. For details, see [Sessions and the server](/guide/concepts/sessions). ## 3. Send keys Type a command into the session, then press Enter: ```sh [Terminal] zeropty send-keys -t demo -l 'sleep 1; echo "done: $((6 * 7))"' zeropty send-keys -t demo Enter ``` `-t demo` is the **target**, the session to send to. `-l` sends the text literally. `Enter` on its own is a key name, so it sends the Enter key. ## 4. Wait for output with wait-pane Wait until `done: 42` appears on the screen, for at most 10 seconds: ```sh [Terminal] zeropty wait-pane -t demo -m 'done: 42' -T 10000 # no polling: returns the moment the text appears ``` The command returns about one second later, as soon as the shell prints the line. `-T` is the timeout in milliseconds. After it, `wait-pane` exits with code 1. ::: tip Wait for the result, not the typed line Typed text is echoed to the screen, as on a real terminal. Wait for text that only the result prints. Here, the typed line contains `$((6 * 7))`, not `42`. ::: ## 5. Read the screen Print the current screen of the session: ```sh [Terminal] zeropty capture-pane -p -t demo ``` `-p` prints the screen to standard output. Without it, the screen goes into a buffer, as in tmux. ## Verify You should see the typed line and its result: ```text sleep 1; echo "done: $((6 * 7))" done: 42 ``` The screen is 6 rows (`-y 6`), so 4 empty rows follow, which are left out above. If `capture-pane` prints nothing, check that the session exists with `zeropty ls`. ## 6. Attach and detach, then stop the server To watch the session yourself, attach your terminal to it: ```sh [Terminal] zeropty attach -t demo ``` Detach with `Ctrl-b d`. The session keeps running. When you are done, stop the server and every session in it: ```sh [Terminal] zeropty kill-server ``` For the list of commands, run `zeropty --help`. For one command, run `zeropty help send-keys`. ## Next steps - [Drive agents from the shell](/guide/shell): the same flow with real agents, plus the faster zsh function and bash builtin. - [Run claude and other terminal programs](/guide/terminal-programs): start programs that need a terminal with `zeropty-tty`. - [Use zeropty from Node.js](/guide/node): drive sessions from a program over the socket or embedded. - [Screens, keys and waiting](/guide/concepts/screens): how `wait-pane` knows without polling. --- # 시작하기 이 안내를 끝내면 zeropty가 설치되어 있고, 셸을 돌리는 세션이 하나 떠 있고, 그 세션의 출력을 기다린 뒤 화면을 읽어 온 상태가 돼요. 전체 흐름은 명령 다섯 개, 1초쯤이면 끝나요. ## 시작하기 전에 - 맥(macOS 11 이상, Apple silicon·Intel)이나 Linux(x86_64, glibc 2.29 이상)가 필요해요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - 아래 중 하나가 필요해요. - **릴리스 묶음.** macOS에 기본으로 있는 `curl`과 `shasum`만 있으면 돼요. Rust는 필요 없어요. - **소스에서 빌드(macOS).** [Rust](https://rustup.rs)(stable)와 Xcode Command Line Tools(`xcode-select --install`)가 필요해요. - claude나 다른 에이전트는 없어도 돼요. 이 안내에서는 `sh`를 돌려요. ## 1. 설치하기 아래 두 방법 중 하나를 고르세요. ### 방법 A: 릴리스 내려받기 0.1.3부터 [릴리스](https://github.com/CreatiCoding/zeropty/releases)마다 모든 맥용 묶음 하나와 그 `SHA256SUMS`가 붙어 있어요. 둘 다 내려받아 해시를 확인한 뒤 풀어요. ```sh [터미널] v=0.1.6 # or the latest: v=$(curl -fsSLo /dev/null -w '%{url_effective}' https://github.com/CreatiCoding/zeropty/releases/latest | sed 's|.*/v||') curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/zeropty-$v-darwin-universal.tar.gz" curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/SHA256SUMS" shasum -a 256 -c --ignore-missing SHA256SUMS # zeropty-0.1.6-darwin-universal.tar.gz: OK tar -xzf "zeropty-$v-darwin-universal.tar.gz" export PATH="$PWD/zeropty-$v-darwin-universal:$PATH" # add it to ~/.zshrc to keep it zeropty -V ``` `v`에 버전이 들어 있어요. 다른 버전으로 고정하려면 `0.1.6`를 바꾸세요. 마지막 명령은 버전을 출력해요. 예를 들면 이래요. ```text zeropty 0.1.6 ``` ::: warning 브라우저로 받았나요? 파일은 ad-hoc 서명이고 공증은 받지 않았어요. `curl`은 quarantine 표시를 붙이지 않아서 위 순서대로면 그대로 실행돼요. 브라우저로 받으면 표시가 붙고, macOS가 창을 띄워 `zeropty`를 막아요. 처음 실행하기 전에 표시를 지우세요: `xattr -dr com.apple.quarantine zeropty-$v-darwin-universal`. ::: Linux(x86_64)에서는 `zeropty-$v-linux-x86_64.tar.gz`를 받아 `sha256sum -c --ignore-missing SHA256SUMS`로 확인하고, `zeropty-$v-linux-x86_64`를 `PATH`에 넣어요. 명령 전체는 [설치와 지원 플랫폼](/ko/guide/install#설치-방법)을 보세요. ### 방법 B: 소스에서 빌드하기 빌드하면 임베딩용 라이브러리와 bash builtin까지 전부 생겨요. ```sh [터미널] git clone https://github.com/CreatiCoding/zeropty && cd zeropty ./build.sh # puts zeropty and the embedding libraries in dist/ export PATH="$PWD/dist:$PATH" ``` 첫 `./build.sh`는 깨끗한 clone과 빈 cargo 캐시로 Apple M1(8코어)에서 23~27초 걸렸어요. ## 2. 세션 띄우기 80열 6행 화면에서 `sh`를 돌리는 `demo` 세션을 띄워요. ```sh [터미널] zeropty new-session -d -s demo -x 80 -y 6 sh # starts the server if none is running ``` `-d`는 내 터미널을 비워 두고, `-s demo`는 세션 이름을 정해요. 돌고 있는 서버가 없었으니 이 명령이 서버도 띄워요. 자세한 내용은 [세션과 서버](/ko/guide/concepts/sessions)를 보세요. ## 3. 키 보내기 세션에 명령을 입력하고 Enter를 눌러요. ```sh [터미널] zeropty send-keys -t demo -l 'sleep 1; echo "done: $((6 * 7))"' zeropty send-keys -t demo Enter ``` `-t demo`는 **타깃**, 즉 키를 보낼 세션이에요. `-l`은 글자를 그대로 보내요. `Enter`만 따로 쓰면 키 이름이라서 Enter 키를 보내요. ## 4. wait-pane으로 출력 기다리기 화면에 `done: 42`가 나타날 때까지 최대 10초 기다려요. ```sh [터미널] zeropty wait-pane -t demo -m 'done: 42' -T 10000 # no polling: returns the moment the text appears ``` 셸이 그 줄을 출력하자마자, 1초쯤 뒤에 명령이 돌아와요. `-T`는 밀리초 단위 제한 시간이에요. 시간이 지나면 `wait-pane`은 종료 코드 1로 끝나요. ::: tip 입력한 줄이 아니라 결과를 기다리세요 실제 터미널처럼 입력한 글자는 화면에 에코돼요. 결과에만 나오는 글자를 기다리세요. 여기서 입력한 줄에는 `42`가 아니라 `$((6 * 7))`이 들어 있어요. ::: ## 5. 화면 읽기 세션의 지금 화면을 출력해요. ```sh [터미널] zeropty capture-pane -p -t demo ``` `-p`는 화면을 표준 출력으로 내요. 없으면 tmux처럼 화면이 버퍼에 들어가요. ## 확인하기 입력한 줄과 그 결과가 보여야 해요. ```text sleep 1; echo "done: $((6 * 7))" done: 42 ``` 화면이 6줄(`-y 6`)이라 그 아래 빈 줄 4개가 이어지는데, 위에서는 생략했어요. `capture-pane`이 아무것도 출력하지 않으면 `zeropty ls`로 세션이 있는지 확인하세요. ## 6. 붙었다 떼고, 서버 끄기 세션을 직접 보려면 내 터미널을 붙여요. ```sh [터미널] zeropty attach -t demo ``` `Ctrl-b d`로 떼요. 세션은 계속 돌아요. 다 썼으면 서버와 그 안의 모든 세션을 꺼요. ```sh [터미널] zeropty kill-server ``` 명령 목록은 `zeropty --help`, 명령 하나는 `zeropty help send-keys`로 봐요. ## 다음 단계 - [셸에서 에이전트 다루기](/ko/guide/shell): 실제 에이전트로 같은 흐름을 해 보고, 더 빠른 zsh 함수와 bash builtin도 써 봐요. - [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs): 터미널이 필요한 프로그램을 `zeropty-tty`로 띄워요. - [Node.js에서 zeropty 쓰기](/ko/guide/node): 프로그램에서 소켓이나 임베디드로 세션을 다뤄요. - [화면, 키, 기다리기](/ko/guide/concepts/screens): `wait-pane`이 폴링 없이 아는 방법. --- # Install and platform support This page lists the platforms zeropty supports, compares the ways to install it, and explains each file you get. For a first install with every command, see [Get started](/guide/getting-started#1-install). ## Supported platforms | Platform | Support | |---|---| | macOS 11 or later, Apple silicon | Supported. The release binaries load on macOS 11 and later | | macOS 11 or later, Intel | Supported by the release archive (`zeropty` and `shim.dylib` carry an `x86_64` slice) | | Linux x86_64, glibc 2.29 or later, kernel 5.3 or later | Supported since 0.1.6 by the Linux release archive (Ubuntu 20.04, Debian 11 and later) | | Tested on | macOS 15 and macOS 26; Ubuntu 22.04 and 24.04 | | Linux arm64 | Not yet | | Windows | Not supported | The engine waits on kqueue on macOS and on epoll on Linux. The **shim** that fools programs which need a terminal is `shim.dylib`, loaded with `DYLD_INSERT_LIBRARIES`, on macOS, and `shim.so`, loaded with `LD_PRELOAD`, on Linux. On Linux a statically linked program, such as most Go binaries, never loads the shim and sees no terminal. Building from source needs [Rust](https://www.rust-lang.org) 1.88 or later, the minimum supported Rust version (MSRV). For details, see [Versioning](/guide/versioning). ## Install methods | Method | Needs | You get | Leaves out | |---|---|---|---| | Release archive | `curl`, `shasum` (`sha256sum` on Linux) | `zeropty`, the shim, `zeropty-tty`, `claude-zeropty` | C and Node embedding libraries, bash builtin | | `./build.sh` from source (macOS) | Rust stable, Xcode Command Line Tools | Everything, in `dist/` | Nothing | | `cargo install --git` | Rust | `zeropty` only, in `~/.cargo/bin` | The shim, `zeropty-tty`, `claude-zeropty`, embedding libraries, bash builtin | - **Release archive.** Each release since 0.1.3 carries `zeropty-VERSION-darwin-universal.tar.gz`, and since 0.1.6 also `zeropty-VERSION-linux-x86_64.tar.gz`, with one `SHA256SUMS` for both. Replace `VERSION` with the release number, such as `0.1.6`. Check the archive before you unpack it: `shasum -a 256 -c --ignore-missing SHA256SUMS` on macOS, `sha256sum -c --ignore-missing SHA256SUMS` on Linux (`--ignore-missing` skips the archive you did not download). To pin a version in a script, fix the version and the archive's hash from that release's `SHA256SUMS`. - **`./build.sh`.** Clone the repository and run `./build.sh`. It builds the workspace in release mode and copies the results into `dist/`. To keep using it, put `dist/` on your `PATH`. - **`cargo install`.** One line, if you need only the CLI and the server: ```sh [Terminal] cargo install --git https://github.com/CreatiCoding/zeropty # ~/.cargo/bin/zeropty ``` This is enough to run programs that need no terminal, such as `sh`, `python3` and build commands. `cargo install` installs only a package's executables, so it leaves out the files that `./build.sh` builds separately. For details, see [FAQ](/guide/faq#what-is-missing-when-i-install-with-cargo-install). There is no Homebrew tap, and the crate is not on crates.io. A Rust program can depend on the engine through a git dependency. For details, see [Embed zeropty in C or Rust](/guide/embed). On Linux, the same steps with the Linux archive: ```sh [Terminal] v=0.1.6 curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/zeropty-$v-linux-x86_64.tar.gz" curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/SHA256SUMS" sha256sum -c --ignore-missing SHA256SUMS # zeropty-0.1.6-linux-x86_64.tar.gz: OK tar -xzf "zeropty-$v-linux-x86_64.tar.gz" export PATH="$PWD/zeropty-$v-linux-x86_64:$PATH" # add it to ~/.bashrc to keep it ``` ::: warning Gatekeeper (macOS) The release files are signed ad hoc, not notarized. A browser download carries a quarantine mark, and macOS then refuses `zeropty`. Clear it with `xattr -dr com.apple.quarantine FOLDER`, replacing `FOLDER` with the unpacked folder. `curl` adds no mark. ::: ## What each file is for | File | In the archive | In `dist/` | For | |---|---|---|---| | `zeropty` | Yes (arm64 and x86_64) | Yes | The server and the CLI | | `shim.dylib` (macOS) | Yes (arm64, arm64e, x86_64) | Yes (arm64, arm64e) | Makes a session's socket look like a terminal to the program | | `shim.so` (Linux) | Yes (x86_64) | No | The same, loaded with `LD_PRELOAD` | | `zeropty-tty` | Yes | Yes | Starts a program with the shim injected (vim, less, claude) | | `claude-zeropty` | Yes | Yes | Runs `zeropty-tty claude` | | `libzeropty_c.dylib`, `zeropty.h` | No | Yes | Embedding the engine through the C ABI (application binary interface) | | `zeropty.node` | No | Yes | Embedding the engine in Node.js, loaded by `client/embedded.mjs` | | `zeropty_bash.dylib` | No | Yes | The bash builtin: `enable -f dist/zeropty_bash.dylib zeropty` | | `LICENSE-MIT`, `LICENSE-APACHE` | Yes | No | The licenses | Keep these files together. `zeropty-tty` finds the shim next to itself, and `claude-zeropty` finds `zeropty-tty` the same way. Put the folder on your `PATH` rather than symlinking `zeropty-tty` or `claude-zeropty` elsewhere, or point `ZEROPTY_SHIM` at the shim. The zsh function, `shell/zeropty.zsh`, is in the repository rather than in `dist/`. Load it with `source shell/zeropty.zsh`. ## Files zeropty creates | Path | What | |---|---| | `~/.zeropty/mux.sock` | The server socket (change it with `ZEROPTY_SOCK`) | | `~/.zeropty/tty/` | macOS only: re-signed copies of programs that `zeropty-tty` runs, such as those in `/usr/bin` (change the parent with `ZEROPTY_CACHE`). Linux runs every program as it is | ## Stop and uninstall 1. Stop the server and every session in it: ```sh [Terminal] zeropty kill-server ``` The server sends SIGHUP to every session's process group and exits. For a server on another socket, set `ZEROPTY_SOCK` as well. 2. Remove the folder you unpacked or cloned, and remove the `PATH` line you added to `~/.zshrc`. 3. If you installed with `cargo install`, run `cargo uninstall zeropty`. 4. Remove `~/.zeropty` to delete the socket file and the copies made by `zeropty-tty`. ## Next steps - [Get started](/guide/getting-started): run your first session. - [How a session gets a terminal](/guide/concepts/terminal): why the shim and `zeropty-tty` exist. - [Embed zeropty in C or Rust](/guide/embed): use the libraries that `./build.sh` puts in `dist/`. --- # 설치와 지원 플랫폼 이 페이지는 zeropty가 지원하는 플랫폼을 적고, 설치 방법을 비교하고, 받는 파일마다 무엇인지 설명해요. 모든 명령을 따라 하는 첫 설치는 [시작하기](/ko/guide/getting-started#1-설치하기)를 보세요. ## 지원 플랫폼 | 플랫폼 | 지원 | |---|---| | macOS 11 이상, Apple silicon | 지원해요. 릴리스 바이너리가 macOS 11 이상에서 로드돼요 | | macOS 11 이상, Intel | 릴리스 묶음으로 지원해요(`zeropty`와 `shim.dylib`에 `x86_64` 슬라이스가 있어요) | | Linux x86_64, glibc 2.29·커널 5.3 이상 | 0.1.6부터 Linux 릴리스 묶음으로 지원해요(Ubuntu 20.04·Debian 11 이상) | | 확인한 버전 | macOS 15, macOS 26, Ubuntu 22.04, Ubuntu 24.04 | | Linux arm64 | 아직 없어요 | | Windows | 지원하지 않아요 | 엔진은 macOS에서 kqueue, Linux에서 epoll을 기다려요. 터미널이 필요한 프로그램을 속이는 **shim**은 macOS에서 `DYLD_INSERT_LIBRARIES`로 거는 `shim.dylib`, Linux에서 `LD_PRELOAD`로 거는 `shim.so`예요. Linux에서 정적 링크된 프로그램(대부분의 Go 바이너리)은 shim을 불러오지 않아서 터미널을 못 봐요. 소스에서 빌드하려면 최소 지원 Rust 버전(MSRV)인 [Rust](https://www.rust-lang.org) 1.88 이상이 필요해요. 자세한 내용은 [버전 정책](/ko/guide/versioning)을 보세요. ## 설치 방법 | 방법 | 필요한 것 | 받는 것 | 빠지는 것 | |---|---|---|---| | 릴리스 묶음 | `curl`, `shasum`(Linux는 `sha256sum`) | `zeropty`, shim, `zeropty-tty`, `claude-zeropty` | C·Node 임베딩 라이브러리, bash builtin | | 소스에서 `./build.sh`(macOS) | Rust stable, Xcode Command Line Tools | 전부, `dist/`에 | 없어요 | | `cargo install --git` | Rust | `zeropty`만, `~/.cargo/bin`에 | shim, `zeropty-tty`, `claude-zeropty`, 임베딩 라이브러리, bash builtin | - **릴리스 묶음.** 0.1.3부터 릴리스마다 `zeropty-VERSION-darwin-universal.tar.gz`가, 0.1.6부터는 `zeropty-VERSION-linux-x86_64.tar.gz`도 붙어 있고, `SHA256SUMS`는 둘을 함께 담아요. `VERSION`은 `0.1.6` 같은 릴리스 번호로 바꾸세요. 풀기 전에 묶음을 확인하세요. macOS는 `shasum -a 256 -c --ignore-missing SHA256SUMS`, Linux는 `sha256sum -c --ignore-missing SHA256SUMS`예요(`--ignore-missing`은 받지 않은 묶음을 건너뛰어요). 스크립트에서 버전을 고정하려면 버전과, 그 릴리스 `SHA256SUMS`에 있는 묶음 해시를 고정해 두세요. - **`./build.sh`.** 저장소를 clone하고 `./build.sh`를 실행해요. 워크스페이스를 release 모드로 빌드하고 결과를 `dist/`에 복사해요. 계속 쓰려면 `dist/`를 `PATH`에 넣어 두세요. - **`cargo install`.** CLI와 서버만 필요하면 한 줄이에요. ```sh [터미널] cargo install --git https://github.com/CreatiCoding/zeropty # ~/.cargo/bin/zeropty ``` `sh`, `python3`, 빌드 명령처럼 터미널이 없어도 되는 프로그램을 돌리기에는 이걸로 충분해요. `cargo install`은 패키지의 실행 파일만 설치해서, `./build.sh`가 따로 빌드하는 파일은 빠져요. 자세한 내용은 [FAQ](/ko/guide/faq#cargo-install로-설치하면-무엇이-빠지나요)를 보세요. Homebrew tap은 없고, crate는 crates.io에 올라가 있지 않아요. Rust 프로그램은 git 의존성으로 엔진을 쓸 수 있어요. 자세한 내용은 [C와 Rust에 zeropty 내장하기](/ko/guide/embed)를 보세요. Linux에서는 Linux 묶음으로 같은 순서를 따라요. ```sh [터미널] v=0.1.6 curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/zeropty-$v-linux-x86_64.tar.gz" curl -fLO "https://github.com/CreatiCoding/zeropty/releases/download/v$v/SHA256SUMS" sha256sum -c --ignore-missing SHA256SUMS # zeropty-0.1.6-linux-x86_64.tar.gz: OK tar -xzf "zeropty-$v-linux-x86_64.tar.gz" export PATH="$PWD/zeropty-$v-linux-x86_64:$PATH" # 계속 쓰려면 ~/.bashrc에 넣어요 ``` ::: warning Gatekeeper (macOS) 릴리스 파일은 ad-hoc 서명이고 공증은 받지 않았어요. 브라우저로 받으면 quarantine 표시가 붙고, macOS가 `zeropty`를 막아요. `xattr -dr com.apple.quarantine FOLDER`로 지우세요. `FOLDER`는 푼 폴더로 바꾸세요. `curl`은 표시를 붙이지 않아요. ::: ## 파일마다의 용도 | 파일 | 묶음에 | `dist/`에 | 용도 | |---|---|---|---| | `zeropty` | 있어요 (arm64, x86_64) | 있어요 | 서버와 CLI | | `shim.dylib`(macOS) | 있어요 (arm64, arm64e, x86_64) | 있어요 (arm64, arm64e) | 세션의 소켓을 프로그램에게 터미널처럼 보이게 해요 | | `shim.so`(Linux) | 있어요 (x86_64) | 없어요 | 같은 일을 `LD_PRELOAD`로 해요 | | `zeropty-tty` | 있어요 | 있어요 | shim을 넣어 프로그램을 띄워요(vim, less, claude) | | `claude-zeropty` | 있어요 | 있어요 | `zeropty-tty claude`를 실행해요 | | `libzeropty_c.dylib`, `zeropty.h` | 없어요 | 있어요 | C ABI(애플리케이션 바이너리 인터페이스)로 엔진 넣기 | | `zeropty.node` | 없어요 | 있어요 | Node.js에 엔진 넣기, `client/embedded.mjs`가 불러요 | | `zeropty_bash.dylib` | 없어요 | 있어요 | bash builtin: `enable -f dist/zeropty_bash.dylib zeropty` | | `LICENSE-MIT`, `LICENSE-APACHE` | 있어요 | 없어요 | 라이선스 | 이 파일들은 한곳에 두세요. `zeropty-tty`는 자기 옆의 shim을 찾고, `claude-zeropty`도 같은 방식으로 `zeropty-tty`를 찾아요. `zeropty-tty`나 `claude-zeropty`만 다른 곳에 심볼릭 링크하지 말고 폴더를 `PATH`에 넣거나, `ZEROPTY_SHIM`으로 shim을 가리켜 주세요. zsh 함수 `shell/zeropty.zsh`는 `dist/`가 아니라 저장소에 있어요. `source shell/zeropty.zsh`로 불러요. ## zeropty가 만드는 파일 | 경로 | 무엇 | |---|---| | `~/.zeropty/mux.sock` | 서버 소켓(`ZEROPTY_SOCK`으로 바꿔요) | | `~/.zeropty/tty/` | macOS만: `zeropty-tty`가 실행하는 프로그램(`/usr/bin`에 있는 것 등)의 서명을 바꾼 사본(상위 폴더는 `ZEROPTY_CACHE`로 바꿔요). Linux는 프로그램을 그대로 실행해요 | ## 끄기와 지우기 1. 서버와 그 안의 모든 세션을 꺼요. ```sh [터미널] zeropty kill-server ``` 서버는 모든 세션의 프로세스 그룹에 SIGHUP을 보내고 끝나요. 다른 소켓의 서버라면 `ZEROPTY_SOCK`도 함께 주세요. 2. 풀었거나 clone한 폴더를 지우고, `~/.zshrc`에 넣은 `PATH` 줄도 지워요. 3. `cargo install`로 설치했다면 `cargo uninstall zeropty`를 실행해요. 4. `~/.zeropty`를 지워서 소켓 파일과 `zeropty-tty`가 만든 사본을 없애요. ## 다음 단계 - [시작하기](/ko/guide/getting-started): 첫 세션을 돌려 봐요. - [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal): shim과 `zeropty-tty`가 왜 있는지. - [C와 Rust에 zeropty 내장하기](/ko/guide/embed): `./build.sh`가 `dist/`에 만드는 라이브러리를 써요. --- # Four ways to drive zeropty This page compares the ways a person or a program can send commands to the zeropty engine, and the plugins that extend it. For working code, see [Use zeropty from Node.js](/guide/node), [Embed zeropty in C or Rust](/guide/embed) and [Write a plugin](/guide/plugins). ```mermaid flowchart LR subgraph out["Other processes"] cli["CLI: zeropty ..."] shell["zsh function · bash builtin"] sock["any language: JSON line"] sp["socket plugin"] end subgraph host["Engine process"] srv["socket server
(zeropty serve, or listen())"] eng["engine"] emb["embedded call:
C ABI · Node · Rust"] ip["in-process plugin
(own thread)"] end cli --> srv shell --> srv sock --> srv sp --> srv srv --> eng emb -->|"function call"| eng ip --> eng ``` *Every path ends at the same engine (in `zeropty serve` or in your own program) and takes the same command; they differ in how many process boundaries a command crosses.* ## One command format Every interface takes the same command: an argument list such as `["send-keys", "-t", "=work:", "Enter"]`. Every interface returns the same result: an exit `code`, `out` and `err`. Code written for one interface moves to another by changing only how the command is sent. ## The four ways ### CLI The `zeropty` program sends one command to the server's socket and exits. You write `zeropty` where you would write `tmux`. Each call starts a process, which costs 3.68~3.92ms per `send-keys -l` from a shell script. From inside a shell, two helpers talk to the socket without starting a process: - **zsh function.** `source shell/zeropty.zsh` makes `zeropty` a shell function: `send-keys -l` at p50 0.12ms. - **bash builtin.** `enable -f dist/zeropty_bash.dylib zeropty` makes it a builtin: `send-keys -l` at 0.043~0.048ms. Both fall back to the program for `attach`, `serve`, `restart-server`, help and version, and when no server is running. ### Socket Any language can send one line of JSON to the server socket and read one line back: ```text → {"argv":["capture-pane","-p","-t","=work:"],"cwd":"/"} ← {"code":0,"out":"...screen...","err":""} ``` One connection carries many commands, answered in order. `cwd` is the base for relative paths. For Node.js, `client/zeropty.mjs` keeps one connection open and opens a separate one for each `wait-pane`. For the format, see [Socket protocol](/reference/socket-protocol). ### Embedded The **embedded** engine runs inside your process, with no server process. A command is a function call. It is available through the C ABI (application binary interface: `libzeropty_c.dylib` and `zeropty.h`, usable from any language with an FFI, or foreign function interface), a Node.js module (`client/embedded.mjs`) and a Rust crate. The embedded engine runs in one of two modes: | Mode | How it runs | Calls from | 400 idle sessions | |---|---|---|---| | **Engine thread** (`zeropty_open`, Rust `Embedded`, Node `{ polled: false }`) | The engine has its own thread | Any thread | 0.74MB | | **Polled** (`zeropty_open_polled`, Rust `Polled`, the Node default) | Your event loop drives the engine through its fd and timeout | The loop's thread only | 0.45~0.46MB | Three things differ from a server: - Sessions end when your process exits. Hand them to a server first with `push-sessions` to keep them. - `wait-pane` is not available as a function call. It works over the socket once you call `listen`, and Rust hosts can use `Engine::wait_start` and `wait_check`. - `listen(SOCKET)` opens a socket in your process, so the CLI, `attach` and socket clients reach the same sessions. Replace `SOCKET` with a path of 103 bytes or less. ### Plugins A **plugin** is code outside zeropty that listens to session events and adds commands. Commands a plugin adds are called like built-in ones, from every interface above. | Way to attach | Language | Where it runs | |---|---|---| | Socket | Any (socket + JSON) | Its own process | | Plugin library (`.dylib`) | C, or anything that exports C functions | A separate process via `zeropty plugin-run`, or inside the host | | C ABI callback | C, or anything that passes C functions | The host process, on its own plugin thread | | Rust trait | Rust | The host process, on its own plugin thread | - **Isolation.** Each plugin runs on its own thread or in its own process. The engine keeps running when a plugin is slow, hangs or panics. - **Access.** A plugin can only run the commands its declared access allows. The default, `read`, can only look. - **Autostart.** If `ZEROPTY_PLUGIN_DIR` is set, the server starts the executables and `.dylib` files in that directory and restarts them when they exit, including after `restart-server`. If it is not set, nothing is run. - **No plugins, no cost.** The output path adds one branch, and commands read one extra atomic variable. ## Comparison `send-keys` latency from a Node.js program, p50 over three runs, with 200 busy sessions per server (Apple M1): | Way | How a command travels | `send-keys` p50 | |---|---|---| | tmux CLI | Start a tmux process per command | 9.03~9.56ms | | tmux control mode (`-C`) | One tmux client, commands on stdin | 0.42~0.49ms | | zeropty socket | One connection, one JSON line | 0.24~0.26ms | | zeropty embedded | Function call in the same process | 0.06~0.07ms | Against running tmux per command, the socket is 34.7~39.8x faster and the embedded engine 128~159x. Against tmux control mode, the socket is 1.6~2.0x and embedded 6.0~8.1x. A round trip between two processes is the floor for any socket method. ## When to choose which | You want to | Choose | |---|---| | Replace `tmux` in scripts, or try zeropty by hand | CLI | | Send many commands from a shell script | zsh function or bash builtin | | Drive sessions from a program in any language, sharing them with people and the CLI | Socket | | The lowest latency and memory, with sessions owned by your program | Embedded (polled from an event loop, engine thread from many threads) | | Embedded speed, but still reachable by `attach` and the CLI | Embedded plus `listen` | | React to session events or add a command | Plugin | ## Why it works this way - **Process start is most of a command's cost.** Even an empty C program takes 1.3ms to start. Each step down the table removes a process start or a process boundary. - **One format keeps the choice open.** Because every interface takes the same argument list, you can start with the CLI and move to the socket or the embedded engine without rewriting commands. - **Embedding trades lifetime for speed.** With no server, sessions live only as long as your process. `listen`, `push-sessions` and `pull-sessions` give back sharing and persistence when you need them. - **Plugins are isolated by default.** Running each on its own thread or process, with `read` access and autostart off, keeps a faulty plugin from stopping the engine or running unasked. ## Next steps - [Use zeropty from Node.js](/guide/node): the socket client and the embedded module. - [Embed zeropty in C or Rust](/guide/embed): engine thread and polled mode in code. - [Write a plugin](/guide/plugins): attach a plugin over the socket or as a `.dylib`. - [Benchmarks](/guide/benchmarks#latency-node-benchlatencymjs-200): every latency measurement. --- # Screens, keys and waiting This page explains how a session's output becomes a screen, how commands read it and send keys, and how `wait-pane` knows when something happened without polling. For the steps in a script, see [Drive agents from the shell](/guide/shell). ```mermaid flowchart TB p["program"] -->|"output on the socketpair"| k["kqueue / epoll"] k -->|"readable"| r["engine: batched read
(300ms boundary)"] r --> s["screen + scrollback
6-byte cells"] s -->|"quiet 250ms"| f["frozen (~1KB)"] f -->|"quiet 10s"| d["deep frozen (zstd)"] c["capture-pane · grep-panes"] -->|"read pending output first"| s w["wait-pane"] -->|"checked on each read,
changed rows only"| s r -->|"events"| pl["plugins"] ``` *Output is read in batches into a compact screen; readers always process pending output first, and waiters are checked only when output arrives.* ## The screen The engine parses each session's output with its own screen parser. Its results are compared with the [`vt100` crate](https://crates.io/crates/vt100) on recorded claude sessions and thousands of sets of random input. ```text Cell (6 bytes, packed) ┌───────────────────────────────┬──────────────────┐ │ ch: u32 │ attr: u16 │ │ code point + wide-char flag │ palette index │ │ (intern id if >1 code point) │ (color + font) │ └───────────────────────────────┴──────────────────┘ ``` - A cell is 6 bytes; the `vt100` crate uses 32. - A row holds cells only up to its last non-empty column, so empty space costs nothing. ### Folding quiet screens Most sessions of an agent host sit still most of the time. The longer a screen stays quiet, the smaller the form it is kept in. Output or a read unfolds it again. | State | When | Size | |---|---|---| | Expanded rows | While output is parsed | Full cells | | Packed rows | Right after a read touches them | 1 byte per ASCII character, repeated cells stored once | | Frozen | 250ms of quiet (`ZEROPTY_FREEZE_MS`) | The whole screen in one chunk; a claude screen is about 1KB | | Deep frozen | 10 seconds of quiet | Compressed again with zstd, kept in an unnamed temporary file | Set `ZEROPTY_COLD=memory` to keep deep-frozen screens in process memory instead of the file. ### Batched reads, current answers Output is not read the moment it arrives. It is collected until a 300ms boundary (`ZEROPTY_COALESCE_MS`) and read all at once. If 16KB or more piles up, or a person is attached, it is read right away. Batching never makes a reader see an old screen. `capture-pane`, `grep-panes` and `wait-pane` first read and parse any pending output, then answer. ## Reading the screen: capture-pane `capture-pane` reads the screen as text, with the same options and line numbers as tmux: ```sh [Terminal] zeropty capture-pane -p -e -S -200 -t '=work:' # screen + 200 lines of scrollback, with colors ``` | Option | Meaning | |---|---| | `-p` | Print to standard output (without it, the screen goes into a buffer) | | `-e` | Include color and font escapes | | `-J` | Join lines that were split by wrapping | | `-N` | Keep trailing spaces | | `-S`, `-E` | First and last line: 0 is the first screen line, negative numbers are scrollback, `-` means to the end | `display-message -p` prints format variables such as `#{cursor_x}`, `#{alternate_on}` and `#{history_size}`. For the full list, see [Format variables](/reference/keys-and-formats#format-variables). ## Sending keys: send-keys `send-keys` sends each argument either as a key or as text: ```sh [Terminal] zeropty send-keys -t '=work:' -l 'Fix the failing test' zeropty send-keys -t '=work:' Enter ``` - An argument that is a **key name** sends that key's bytes: `Enter`, `Escape`, `Tab`, `BSpace`, `Up`, `PageUp`, `F1`~`F12` and more. Prefix `C-`, `M-` or `S-` for Ctrl, Alt or Shift (`C-c`, `M-x`, `S-Up`). Names are case-insensitive, and the bytes were checked against tmux 3. - Anything else is sent as literal text. `-l` sends every argument literally, `-H` reads hexadecimal bytes, and `-N` repeats. - When a program turns on application cursor mode (vim and others), arrow keys switch to the `ESC O A` form, as in tmux. Keys then pass through the line discipline. For details, see [How a session gets a terminal](/guide/concepts/terminal#the-line-discipline). ## Waiting: wait-pane `wait-pane` answers when something happens on a screen. tmux has no such command. | Option | Answers when | |---|---| | `-m TEXT` | `TEXT` appears on the screen (at once if it is already there) | | `-q MS` | The session has printed nothing for `MS` milliseconds | | Both | Both hold | | `-T MS` | Not met within `MS` milliseconds: exit code 1, `wait-pane: timed out` | Replace `TEXT` and `MS` with your text and times. If the session ends while waiting, `wait-pane` exits with code 1 and `wait-pane: session ended`. **How it is notified.** The engine checks a waiting session only when its output arrives, and only the rows that output changed. Waiting sessions are read immediately rather than every 300ms. So nothing polls: the host sends one request and gets one answer. Text is matched within a row, so text split by a line wrap is not found. With 200 sessions replaying real claude work, measured on an Apple M1: | Method | Server CPU | Host (Node) CPU | Detection latency p50 / p95 | |---|---|---|---| | 500ms polling | 2.0% | 0.6% | 253~262 / 479~489ms | | 200ms polling | 2.9~3.0% | 1.1~1.3% | 86~96 / 173~196ms | | `wait-pane` | 3.8~3.9% | 0.3~0.4% | 1 / 2ms | ::: warning Typed text matches too Typed text is echoed to the screen. If you send `echo done` and wait for `-m done`, the typed line matches at once. Wait for text that only the result prints. ::: ## Searching every screen: grep-panes `grep-panes TEXT` searches every session's screen and prints matches as `session: line`, sorted by session name. `-S` also searches scrollback. The exit code is 1 when nothing matches, like grep. Finding 3 marked screens among 1000 idle claude screens took 16~32ms, against 4.2~4.3 seconds for `capture-pane` per session through the CLI. `grep-panes` reads a copy of a frozen screen, so it does not unfold it. ## Events Plugins can receive what happens to screens as **events**: `session-created`, `session-exited`, `session-renamed`, `output` (the bytes read) and `activity` (the screen changed, without copying bytes). Output events arrive with the batched reads, so quiet sessions report in units of about 300ms. `output` can drop data in a very large burst; to get every byte, use `pipe-pane`. For details, see [Four ways to drive zeropty](/guide/concepts/interfaces#plugins). ## Why it works this way - **Most screens are quiet.** Folding them away is how quiet screens use almost no memory: 400 sessions replaying claude work took 2.66~2.70MB of server memory. The cost is a small unfold when output or a read arrives. - **Batching saves CPU.** With 400 claude sessions, CPU was 5.2% at a 100ms read interval and 3.7% at 300ms. Reading pending output before every screen read keeps answers current. - **Waiting costs a little CPU.** `wait-pane` raised server CPU by 0.8~1.0 percentage points over 200ms polling, because waiting sessions are read at once. In exchange, detection goes from p50 86~262ms to 1ms, and the host's polling code goes away. ## Next steps - [Drive agents from the shell](/guide/shell): `send-keys`, `wait-pane` and `capture-pane` in a script. - [Command reference: Waiting and searching](/reference/commands#waiting-and-searching): every option. - [Four ways to drive zeropty](/guide/concepts/interfaces): how commands reach the engine. --- # Sessions and the server This page explains what a session is, which process owns it, and how commands find it. For the steps to start one, see [Get started](/guide/getting-started). ```mermaid flowchart TB subgraph clients["Clients"] cli["zeropty CLI"] sh["zsh function · bash builtin"] node["client/zeropty.mjs"] att["zeropty attach"] end subgraph server["Server process: zeropty serve"] eng["engine: sessions · screens · commands
one thread, one kqueue (epoll on Linux)"] end subgraph sessions["Sessions"] s1["work: claude-zeropty"] s2["build: sh"] s3["..."] end clients -->|"~/.zeropty/mux.sock
one JSON line per command"| eng eng --> s1 eng --> s2 eng --> s3 ``` *Every client talks to one server over a Unix socket, and the server's engine owns every session.* ## A session is one program A **session** is one program and its screen. `new-session` launches a single command with `/bin/sh -c`, as its own process group. Each session has one screen and its scrollback. There are no windows or panes. | tmux | zeropty | |---|---| | Session → windows → panes | Session = one program | | One PTY per pane | No PTY; one socketpair per session | | `split-window`, `new-window` | Not available | | `window_index`, `pane_index` | Always `0`, for script compatibility | Without a command, `new-session` runs `$SHELL`, or `/bin/sh` if it is unset. The default screen is 80×24, and without `-s` the name is the number of sessions created so far (`0`, `1`, ...). The program in a session gets these variables: `TERM=xterm-256color`, `COLUMNS` and `LINES`, `ZEROPTY_SESSION` (the session name), `ZEROPTY_COLS` and `ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE` and `ZEROPTY_TTY_ID`. The last two are for the shim. `TMUX` and `TMUX_PANE` are removed, so a session started from inside tmux is not inside tmux. ## The server and its socket The **server** is the process that runs the engine and owns every session. The `zeropty` CLI sends each command to the server's **socket**, `~/.zeropty/mux.sock` by default. Set `ZEROPTY_SOCK` to use another path, of 103 bytes or less. - **Who starts it.** If no server is running, `new-session` starts one, just like tmux. `pull-sessions` does too. You can also start it yourself with `zeropty serve`. - **Who can connect.** The socket has 0600 permissions, so only the same user (and root) can connect. - **One engine, one thread.** A single thread and a single kqueue (epoll on Linux) handle every session's output, process exits and timers. There is no thread or process per session. - **Not only a separate process.** The same engine can run inside your program instead. For details, see [Four ways to drive zeropty](/guide/concepts/interfaces). If no server answers on the socket, commands fail with `no server running on ...` and exit code 1. ### The server's environment The server keeps the environment of the process that started it, as tmux's does. That is the first command that starts it: `new-session`, `pull-sessions` or `zeropty serve`. Later sessions get that environment, not the environment of the shell that runs each `new-session`. | To set a variable for | Use | |---|---| | One new session | `new-session -e VAR=value` (repeatable) | | Every session created afterwards | `set-environment -g VAR value` | | Remove it for later sessions | `set-environment -g -u VAR` | `restart-server` starts the new server from the shell that runs it. Later sessions then get that shell's environment, and values set with `set-environment -g` are dropped. Running sessions keep theirs. ## Targets A **target** (`-t`) names one session. The forms below all mean the session `work`: ```text =work: =work work work:0 ``` The name must **match exactly**. tmux also matches a prefix, so `-t wo` could reach `work`; zeropty treats that as a missing session. In scripts, write `=name:` as you would with tmux. ::: warning zsh and `=` zsh turns `=word` into the path of a command. Quote the target, as in `-t '=work:'`. The same goes for tmux. ::: ## Scrollback Lines that scroll off the top of the screen go into the session's scrollback. It keeps 500 lines by default; set `ZEROPTY_HISTORY` to change that. `capture-pane` reads scrollback with negative line numbers, the same numbering as tmux: ```sh [Terminal] zeropty capture-pane -p -S -200 -t '=work:' # the screen plus 200 lines of scrollback ``` `grep-panes -S` searches scrollback too. For details, see [Screens, keys and waiting](/guide/concepts/screens). ## When a session ends | Event | What happens | |---|---| | The program exits | The session ends too, as in tmux with `remain-on-exit` off | | `kill-session -t NAME` | SIGHUP to the session's process group; SIGKILL to anything still alive after 2 seconds | | `kill-server` | SIGHUP to every session. A standalone server also exits; an embedded engine keeps running | | The embedding process exits | Its sessions end with it (the programs get EOF) | | `restart-server` | Nothing ends. A new server takes over every session and the socket | Replace `NAME` with the session name. `restart-server` and `pull-sessions` / `push-sessions` move running sessions to another process: the programs keep running and keep their pids. For the steps, see [Move sessions and upgrade the server](/guide/moving). ## Why it works this way - **tmux compatibility without windows.** Agent hosts already drive tmux with `new-session`, `send-keys` and `capture-pane`. Keeping those names and arguments lets you replace `tmux` with `zeropty` in most scripts. Windows and panes exist for people splitting a screen, and an agent host does not need them. - **One program per session keeps the model flat.** A target names exactly one screen, so `-t =work:` is never ambiguous. The trade-off: you cannot split one session into several programs; start several sessions instead. - **Exact names avoid surprises.** With prefix matching, a shortened or mistyped name can still reach some session, and keys go to an agent you did not mean. Exact matching is safer for automation, at the cost of tmux scripts that relied on prefixes. - **The server owns the environment.** This is tmux's rule, so existing scripts behave the same. The cost is that a variable exported in your shell does not reach new sessions unless you pass it with `-e` or `set-environment -g`. ## Next steps - [How a session gets a terminal](/guide/concepts/terminal): what replaces the PTY. - [Command reference: Sessions](/reference/commands#sessions): every option of `new-session`, `kill-session` and the rest. - [Environment variables](/reference/environment): `ZEROPTY_SOCK`, `ZEROPTY_HISTORY` and others. - [Coming from tmux](/guide/from-tmux): what changes in your scripts. --- # How a session gets a terminal This page explains what a session has in place of a PTY (pseudo-terminal), how programs that need a terminal still run, and what the engine does with the keys you send. For the steps to start vim or claude, see [Run claude and other terminal programs](/guide/terminal-programs). ```mermaid flowchart LR subgraph tmux["tmux · a PTY per pane"] direction TB tc["send-keys"] --> ts["tmux server"] ts -->|"master side"| pty["kernel PTY
line discipline · echo · signals"] pty -->|"slave side: stdin, stdout, stderr"| tp["program"] end subgraph zp["zeropty · a socketpair"] direction TB zc["send-keys"] --> ze["engine
line discipline · echo · signals"] ze -->|"socketpair"| zpg["program
(+ the shim via zeropty-tty)"] end tmux ~~~ zp ``` *tmux leaves keys, echo and signals to a kernel PTY; zeropty does that work in the engine and talks to the program over a socketpair.* ## A socketpair instead of a PTY Each session's terminal is a single socketpair. The program's stdin, stdout and stderr are all its one end, as they are one PTY under tmux. The engine holds the other end: it writes keys into it and reads output from it. | | tmux | zeropty | |---|---|---| | Terminal per session | One kernel PTY | One socketpair | | Session limit | macOS PTY limit: 511 by default, 999 at most | No PTY limit; 1000 sessions verified | | PTYs used with 400 sessions | 435 | 35 (the system baseline, the same at 1000) | | Enter, `C-c`, echo, line editing | Kernel line discipline | Emulated by the engine | | Output buffer | PTY buffer | 64KB socket buffer per session | Some limits still apply. macOS limits the number of processes per user (`kern.maxprocperuid`), and one claude session is about 4 processes. ## "Not a terminal" A socket is not a terminal, so `isatty()` returns false in a session. Programs that check, such as vim, less, the python REPL and claude, then decide they are not on a terminal when launched directly. Programs that need no terminal, such as `sh`, `cat`, `python3` scripts and build commands, run as they are. ## The shim and zeropty-tty The **shim** (`shim.dylib` on macOS, `shim.so` on Linux) is a small library loaded into the program that answers terminal calls as a terminal would. **`zeropty-tty`** is the script that starts a program with the shim injected through `DYLD_INSERT_LIBRARIES` (macOS) or `LD_PRELOAD` (Linux): ```sh [Terminal] zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt" ``` | Call the shim answers | What the program gets | |---|---| | `isatty` | "This is a terminal" for the session's socket | | `tcgetattr`, `tcsetattr` | The terminal mode; a mode change is reported to the engine | | `ioctl(TIOCGWINSZ)` | The window size, read from `ZEROPTY_SIZE_FILE` | | `tcgetpgrp`, `tcsetpgrp` | The foreground process group; a change is reported to the engine | | `open("/dev/tty")` | The session's socket, for reading, writing or both | | `readpassphrase`, `getpass` | A prompt that does not echo what you type | Only the session's own socket counts as the terminal. A program whose stdout is piped on (`zeropty-tty prog | cat`) or whose stdin is `/dev/null` sees no terminal there, just as under tmux. Outside a session, the shim answers nothing. When a program changes the terminal mode, the shim slips that fact into the output stream as an OSC (operating system command) sequence that never reaches the screen. The engine therefore knows the exact point from which keys must be handled differently. When the window size changes, for example when a person attaches from a terminal of another size, the engine rewrites the size file and sends SIGWINCH. ### Programs the shim cannot enter macOS does not let the shim into SIP-protected binaries, such as those in `/usr/bin`, or into binaries signed with the hardened runtime. `zeropty-tty` runs such programs from a copy with only the signature changed, kept in `~/.zeropty/tty/`, and rebuilds the copy when the original changes. - The copy does not carry the original's privileges, so `top` and `ps` do not work this way. - Scripts that go through `/usr/bin/env` lose the shim; start the interpreter itself with `zeropty-tty`. - `/usr/bin` programs typed at a shell inside a session run without the shim. Start them with `zeropty-tty` when they need a terminal. ### claude-zeropty `claude-zeropty` is a small wrapper that runs `zeropty-tty claude`. It uses the `claude` on your `PATH`; set `ZEROPTY_CLAUDE_REAL` to use another one. ## The line discipline A **line discipline** is the layer that turns typed keys into what a program reads. On a PTY the kernel does it. In zeropty the engine does it when it sends keys, and the same keys were checked against tmux. A new session starts in cooked mode, the same as a fresh PTY. | Mode | What happens to keys | |---|---| | Cooked (default) | Enter becomes a newline. Input is collected line by line and can be edited with `BSpace`, `C-u`, `C-w` and `C-v`. Typed characters are echoed. `C-d` on an empty line is end of input | | Raw (claude, vim, the python REPL, after the shim reports it) | Keys pass through unchanged | A program without the shim can ask for raw mode by printing `\033]zeropty-tty;0\007`, the equivalent of `stty raw -echo`. ### Signals to the foreground job In cooked mode, `C-c`, `C-\` and `C-z` go to a process group as signals. That is the session's group, or the job a shell with job control (zsh, `bash -i`) gave the terminal to. The shell's `tcsetpgrp` reaches the engine through the shim, so the shell must run with it (`zeropty-tty zsh`). The job itself may be any program, `/usr/bin` included. Only a process group with a member under the session's program is taken. Before 0.1.5, these keys went to the shell instead of the job, so `sleep 30` typed at zsh could not be stopped. A shell started before that upgrade keeps the old shim; start it again. For details, see [Changelog](/guide/changelog). ### Differences from a PTY - **`C-d` shuts input for good.** A socket cannot signal "EOF once", so after `C-d` on an empty line the session accepts no more input. Output still flows. Send `C-c` or the program's own quit command instead, unless you mean to end it. - **`O_NONBLOCK` is shared.** stdin, stdout and stderr are one file, as on a PTY. A program that sets `O_NONBLOCK` on one sets it on all three, for later commands in the session too. ## Why it works this way - **No PTY limit.** tmux opens one PTY per pane and stops at 511 by default, 999 at most. Agent hosts need hundreds to thousands of sessions, so zeropty does not use PTYs at all. - **A socketpair, not a pipe.** Once about 250 macOS pipes have grown, the rest are stuck at 512 bytes. With 1000 sessions, 41% of the output was stuck inside the programs. A separate 64KB socket buffer per session lets everything flow. The cost: when zeropty stops reading, up to 64KB per session waits in kernel buffers (64MB for 1000 sessions). - **The engine as line discipline.** Doing echo and signals in the engine adds about 10µs per key in cooked sessions. Raw-mode sessions such as claude get no extra work. - **The trade-off is the shim.** Programs that need a terminal must start through `zeropty-tty`, and privileged system programs such as `top` cannot run that way. On Linux a statically linked program, such as most Go binaries, never loads the shim. ## Next steps - [Run claude and other terminal programs](/guide/terminal-programs): the steps for vim, zsh and claude. - [Screens, keys and waiting](/guide/concepts/screens): what happens to the output. - [Key names](/reference/keys-and-formats#key-names): every key `send-keys` understands. - [Design notes](/guide/design#1-sockets-instead-of-ptys): the shim and the line discipline in more depth. --- # zeropty를 다루는 네 가지 방법 이 페이지는 사람이나 프로그램이 zeropty 엔진에 명령을 보내는 방법들과, 엔진을 넓히는 플러그인을 비교해요. 돌아가는 코드는 [Node.js에서 zeropty 쓰기](/ko/guide/node), [C와 Rust에 zeropty 내장하기](/ko/guide/embed), [플러그인 작성하기](/ko/guide/plugins)를 보세요. ```mermaid flowchart LR subgraph out["다른 프로세스"] cli["CLI: zeropty ..."] shell["zsh 함수 · bash builtin"] sock["아무 언어: JSON 한 줄"] sp["소켓 플러그인"] end subgraph host["엔진을 돌리는 프로세스"] srv["소켓 서버
(zeropty serve 또는 listen())"] eng["엔진"] emb["임베디드 호출:
C ABI · Node · Rust"] ip["프로세스 안 플러그인
(자기 스레드)"] end cli --> srv shell --> srv sock --> srv sp --> srv srv --> eng emb -->|"함수 호출"| eng ip --> eng ``` *어느 길이든 같은 엔진(`zeropty serve` 또는 내 프로그램 안)에 닿고 같은 명령을 받아요. 다른 점은 명령이 프로세스 경계를 몇 번 넘느냐예요.* ## 명령 형식은 하나 모든 인터페이스가 같은 명령을 받아요. `["send-keys", "-t", "=work:", "Enter"]` 같은 인자 목록이에요. 결과도 모두 같아요. 종료 `code`, `out`, `err`예요. 한 인터페이스로 쓴 코드는 명령을 보내는 방법만 바꾸면 다른 인터페이스로 옮겨요. ## 네 가지 방법 ### CLI `zeropty` 프로그램은 서버 소켓에 명령 하나를 보내고 끝나요. `tmux`를 쓰던 자리에 `zeropty`를 쓰면 돼요. 부를 때마다 프로세스가 떠서, 셸 스크립트에서 `send-keys -l` 한 번에 3.68~3.92ms가 들어요. 셸 안에서는 두 도우미가 프로세스를 띄우지 않고 소켓에 말해요. - **zsh 함수.** `source shell/zeropty.zsh`를 하면 `zeropty`가 셸 함수가 돼요: `send-keys -l` p50 0.12ms. - **bash builtin.** `enable -f dist/zeropty_bash.dylib zeropty`를 하면 builtin이 돼요: `send-keys -l` 0.043~0.048ms. 둘 다 `attach`, `serve`, `restart-server`, 도움말, 버전, 그리고 서버가 없을 때는 프로그램을 실행해요. ### 소켓 어떤 언어든 서버 소켓에 JSON 한 줄을 보내고 한 줄을 읽으면 돼요. ```text → {"argv":["capture-pane","-p","-t","=work:"],"cwd":"/"} ← {"code":0,"out":"...screen...","err":""} ``` 연결 하나로 여러 명령을 보내고, 답은 순서대로 와요. `cwd`는 상대 경로의 기준이에요. Node.js에서는 `client/zeropty.mjs`가 연결 하나를 열어 두고, `wait-pane`마다 연결을 따로 열어요. 형식은 [소켓 프로토콜](/ko/reference/socket-protocol)을 보세요. ### 임베디드 **임베디드** 엔진은 서버 프로세스 없이 내 프로세스 안에서 돌아요. 명령은 함수 호출이에요. C ABI(애플리케이션 바이너리 인터페이스: `libzeropty_c.dylib`와 `zeropty.h`, FFI(외부 함수 인터페이스)가 되는 어떤 언어에서든), Node.js 모듈(`client/embedded.mjs`), Rust crate로 쓸 수 있어요. 임베디드 엔진은 두 모드 중 하나로 돌아요. | 모드 | 어떻게 도나요 | 부를 수 있는 곳 | 대기 세션 400개 | |---|---|---|---| | **엔진 스레드** (`zeropty_open`, Rust `Embedded`, Node `{ polled: false }`) | 엔진이 자기 스레드를 가져요 | 어느 스레드든 | 0.74MB | | **polled** (`zeropty_open_polled`, Rust `Polled`, Node 기본값) | 내 이벤트 루프가 엔진의 fd와 타임아웃으로 엔진을 돌려요 | 그 루프의 스레드만 | 0.45~0.46MB | 서버와 다른 점이 세 가지 있어요. - 세션은 내 프로세스가 끝나면 함께 끝나요. 남기려면 먼저 `push-sessions`로 서버에 넘기세요. - `wait-pane`은 함수 호출로는 쓸 수 없어요. `listen`을 부르면 소켓으로는 되고, Rust 호스트는 `Engine::wait_start`와 `wait_check`를 쓸 수 있어요. - `listen(SOCKET)`은 내 프로세스에 소켓을 열어서, CLI, `attach`, 소켓 클라이언트가 같은 세션에 닿게 해요. `SOCKET`은 103바이트 이하의 경로로 바꾸세요. ### 플러그인 **플러그인**은 zeropty 밖의 코드로, 세션 이벤트를 듣고 명령을 더해요. 플러그인이 더한 명령은 위의 모든 인터페이스에서 내장 명령처럼 불러요. | 붙이는 방법 | 언어 | 도는 곳 | |---|---|---| | 소켓 | 아무 언어 (소켓 + JSON) | 자기 프로세스 | | 플러그인 라이브러리 (`.dylib`) | C, 또는 C 함수를 내보내는 무엇이든 | `zeropty plugin-run`으로 띄운 별도 프로세스, 또는 호스트 안 | | C ABI 콜백 | C, 또는 C 함수를 넘기는 무엇이든 | 호스트 프로세스 안, 자기 플러그인 스레드 | | Rust 트레이트 | Rust | 호스트 프로세스 안, 자기 플러그인 스레드 | - **격리.** 플러그인마다 자기 스레드나 자기 프로세스에서 돌아요. 플러그인이 느리거나 멈추거나 패닉이 나도 엔진은 계속 돌아요. - **권한.** 플러그인은 자기가 밝힌 권한이 허용하는 명령만 실행할 수 있어요. 기본값 `read`는 보기만 해요. - **자동 시작.** `ZEROPTY_PLUGIN_DIR`이 정해져 있으면 서버가 그 디렉터리의 실행 파일과 `.dylib`를 띄우고, 끝나면 다시 띄워요. `restart-server` 뒤에도요. 정해져 있지 않으면 아무것도 실행하지 않아요. - **플러그인이 없으면 비용도 없어요.** 출력 경로에 분기 하나가 더해지고, 명령은 원자 변수 하나를 더 읽어요. ## 비교 Node.js 프로그램에서 잰 `send-keys` 응답 시간이에요. 세 번 돌린 p50이고, 서버마다 바쁜 세션 200개(Apple M1)예요. | 방법 | 명령이 가는 길 | `send-keys` p50 | |---|---|---| | tmux CLI | 명령마다 tmux 프로세스 시작 | 9.03~9.56ms | | tmux control mode (`-C`) | tmux 클라이언트 하나, stdin으로 명령 | 0.42~0.49ms | | zeropty 소켓 | 연결 하나, JSON 한 줄 | 0.24~0.26ms | | zeropty 임베디드 | 같은 프로세스 안 함수 호출 | 0.06~0.07ms | 명령마다 tmux를 실행하는 것과 비교하면 소켓은 34.7~39.8배, 임베디드 엔진은 128~159배 빨라요. tmux control mode와 비교하면 소켓은 1.6~2.0배, 임베디드는 6.0~8.1배예요. 소켓을 쓰는 방법은 어느 것이든 두 프로세스 사이 왕복이 하한이에요. ## 언제 무엇을 고르나요 | 하고 싶은 일 | 고를 것 | |---|---| | 스크립트에서 `tmux`를 바꾸거나, 손으로 zeropty를 써 보기 | CLI | | 셸 스크립트에서 명령을 많이 보내기 | zsh 함수 또는 bash builtin | | 어떤 언어로든 프로그램에서 세션을 다루고, 사람·CLI와 함께 쓰기 | 소켓 | | 응답 시간과 메모리를 가장 낮게, 세션은 내 프로그램이 가지기 | 임베디드 (이벤트 루프에서는 polled, 여러 스레드에서는 엔진 스레드) | | 임베디드 속도에, `attach`와 CLI로도 닿게 | 임베디드 + `listen` | | 세션 이벤트에 반응하거나 명령 더하기 | 플러그인 | ## 왜 이렇게 했나요? - **명령 비용은 대부분 프로세스 시작이에요.** 빈 C 프로그램도 시작에 1.3ms가 걸려요. 표에서 한 칸 내려갈 때마다 프로세스 시작이나 프로세스 경계가 하나씩 빠져요. - **형식이 하나라 선택을 미룰 수 있어요.** 모든 인터페이스가 같은 인자 목록을 받아서, CLI로 시작했다가 명령을 고쳐 쓰지 않고 소켓이나 임베디드 엔진으로 옮길 수 있어요. - **임베딩은 수명을 내주고 속도를 얻어요.** 서버가 없으니 세션은 내 프로세스가 살아 있는 동안만 살아요. 필요하면 `listen`, `push-sessions`, `pull-sessions`로 함께 쓰기와 지속성을 되찾아요. - **플러그인은 기본으로 격리돼요.** 플러그인마다 자기 스레드나 프로세스에서 돌고, 권한은 `read`, 자동 시작은 꺼져 있어서, 잘못된 플러그인이 엔진을 멈추거나 시키지 않은 일을 하지 못해요. ## 다음 단계 - [Node.js에서 zeropty 쓰기](/ko/guide/node): 소켓 클라이언트와 임베디드 모듈. - [C와 Rust에 zeropty 내장하기](/ko/guide/embed): 엔진 스레드와 polled 모드를 코드로. - [플러그인 작성하기](/ko/guide/plugins): 소켓이나 `.dylib`로 플러그인 붙이기. - [벤치마크](/ko/guide/benchmarks#응답-속도-node-benchlatencymjs-200): 모든 응답 시간 측정값. --- # 화면, 키, 기다리기 이 페이지는 세션의 출력이 어떻게 화면이 되는지, 명령이 화면을 읽고 키를 보내는 방법, 그리고 `wait-pane`이 폴링 없이 일이 일어난 걸 아는 방법을 설명해요. 스크립트에서 쓰는 순서는 [셸에서 에이전트 다루기](/ko/guide/shell)를 보세요. ```mermaid flowchart TB p["프로그램"] -->|"socketpair로 출력"| k["kqueue / epoll"] k -->|"읽을 수 있음"| r["엔진: 모아 읽기
(300ms 경계)"] r --> s["화면 + 스크롤백
6바이트 셀"] s -->|"250ms 조용"| f["얼림 (~1KB)"] f -->|"10초 조용"| d["깊이 얼림 (zstd)"] c["capture-pane · grep-panes"] -->|"밀린 출력부터 읽기"| s w["wait-pane"] -->|"읽을 때마다 확인,
바뀐 행만"| s r -->|"이벤트"| pl["플러그인"] ``` *출력은 모아서 작은 화면 구조로 읽어요. 화면을 읽는 쪽은 늘 밀린 출력부터 처리하고, 기다리는 쪽은 출력이 올 때만 확인해요.* ## 화면 엔진은 세션마다 출력을 자체 화면 해석기로 해석해요. 녹화한 claude 세션과 무작위 입력 수천 세트로 [`vt100` crate](https://crates.io/crates/vt100)와 결과를 대조해요. ```text Cell (6 bytes, packed) ┌───────────────────────────────┬──────────────────┐ │ ch: u32 │ attr: u16 │ │ code point + wide-char flag │ palette index │ │ (intern id if >1 code point) │ (color + font) │ └───────────────────────────────┴──────────────────┘ ``` - 셀 하나가 6바이트예요. `vt100` crate는 32바이트예요. - 행은 마지막으로 내용이 있는 칸까지만 셀을 가져서, 빈 공간은 비용이 없어요. ### 조용한 화면 접어 두기 에이전트 호스트의 세션은 대부분 거의 늘 가만히 있어요. 화면이 오래 조용할수록 더 작은 형태로 둬요. 출력이 오거나 누가 읽으면 다시 펼쳐요. | 상태 | 언제 | 크기 | |---|---|---| | 펼친 행 | 출력을 파싱하는 동안 | 셀 전체 | | 행 압축 | 읽기가 건드린 직후 | ASCII 한 글자에 1바이트, 같은 셀 반복은 한 번만 | | 얼림 | 250ms 조용 (`ZEROPTY_FREEZE_MS`) | 화면 통째로 한 덩어리. claude 화면은 약 1KB | | 깊이 얼림 | 10초 조용 | zstd로 한 번 더 압축해서 이름 없는 임시 파일에 둬요 | 깊이 얼린 화면을 파일 대신 프로세스 메모리에 두려면 `ZEROPTY_COLD=memory`를 정하세요. ### 모아 읽어도 답은 최신 출력은 오자마자 읽지 않아요. 300ms 경계(`ZEROPTY_COALESCE_MS`)까지 모았다가 한 번에 읽어요. 16KB 이상 쌓이거나 사람이 붙어 있으면 바로 읽어요. 모아 읽는다고 화면을 읽는 쪽이 옛 화면을 보는 일은 없어요. `capture-pane`, `grep-panes`, `wait-pane`은 밀린 출력부터 읽고 파싱한 뒤 답해요. ## 화면 읽기: capture-pane `capture-pane`은 tmux와 같은 옵션과 줄 번호로 화면을 텍스트로 읽어요. ```sh [터미널] zeropty capture-pane -p -e -S -200 -t '=work:' # screen + 200 lines of scrollback, with colors ``` | 옵션 | 뜻 | |---|---| | `-p` | 표준 출력으로 내요(없으면 화면이 버퍼에 들어가요) | | `-e` | 색·글꼴 이스케이프를 포함해요 | | `-J` | 줄바꿈으로 나뉜 줄을 이어 붙여요 | | `-N` | 줄 끝 공백을 남겨요 | | `-S`, `-E` | 첫 줄과 끝 줄: 0이 화면 첫 줄, 음수가 스크롤백, `-`는 끝까지 | `display-message -p`는 `#{cursor_x}`, `#{alternate_on}`, `#{history_size}` 같은 포맷 변수를 출력해요. 전체 목록은 [포맷 변수](/ko/reference/keys-and-formats#포맷-변수)를 보세요. ## 키 보내기: send-keys `send-keys`는 인자마다 키로 보내거나 글자로 보내요. ```sh [터미널] zeropty send-keys -t '=work:' -l 'Fix the failing test' zeropty send-keys -t '=work:' Enter ``` - **키 이름**인 인자는 그 키의 바이트를 보내요: `Enter`, `Escape`, `Tab`, `BSpace`, `Up`, `PageUp`, `F1`~`F12` 등이에요. Ctrl, Alt, Shift는 `C-`, `M-`, `S-`를 앞에 붙여요(`C-c`, `M-x`, `S-Up`). 이름은 대소문자를 가리지 않고, 바이트는 tmux 3과 대조했어요. - 그 밖의 인자는 글자 그대로 보내요. `-l`은 모든 인자를 글자 그대로, `-H`는 16진수 바이트로 읽고, `-N`은 반복해요. - 프로그램이 application cursor 모드를 켜면(vim 등) 화살표 키가 tmux처럼 `ESC O A` 형태로 바뀌어요. 그다음 키는 라인 디시플린을 거쳐요. 자세한 내용은 [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal#라인-디시플린)을 보세요. ## 기다리기: wait-pane `wait-pane`은 화면에 무슨 일이 일어나면 답해요. tmux에는 없는 명령이에요. | 옵션 | 답하는 때 | |---|---| | `-m TEXT` | `TEXT`가 화면에 나타날 때(이미 있으면 바로) | | `-q MS` | 세션이 `MS`밀리초 동안 아무것도 출력하지 않을 때 | | 둘 다 | 둘 다 만족할 때 | | `-T MS` | `MS`밀리초 안에 만족하지 않으면: 종료 코드 1, `wait-pane: timed out` | `TEXT`와 `MS`는 원하는 글자와 시간으로 바꾸세요. 기다리는 중에 세션이 끝나면 `wait-pane`은 종료 코드 1과 `wait-pane: session ended`로 끝나요. **어떻게 알게 되나요.** 엔진은 기다리는 세션을 출력이 올 때만, 그 출력이 바꾼 행만 확인해요. 기다리는 세션은 300ms마다가 아니라 바로 읽어요. 그래서 폴링하는 곳이 없어요. 호스트는 요청 하나를 보내고 답 하나를 받아요. 글자는 행 안에서 찾아서, 줄바꿈으로 나뉜 글자는 찾지 못해요. 실제 claude 작업을 재생하는 세션 200개로 Apple M1에서 쟀어요. | 방법 | 서버 CPU | 호스트(Node) CPU | 알아채기까지 p50 / p95 | |---|---|---|---| | 500ms 폴링 | 2.0% | 0.6% | 253~262 / 479~489ms | | 200ms 폴링 | 2.9~3.0% | 1.1~1.3% | 86~96 / 173~196ms | | `wait-pane` | 3.8~3.9% | 0.3~0.4% | 1 / 2ms | ::: warning 입력한 글자도 일치해요 입력한 글자는 화면에 에코돼요. `echo done`을 보내고 `-m done`을 기다리면 입력한 줄이 바로 일치해요. 결과에만 나오는 글자를 기다리세요. ::: ## 모든 화면 찾기: grep-panes `grep-panes TEXT`는 모든 세션의 화면에서 찾아 `세션: 줄` 형태로, 세션 이름순으로 보여 줘요. `-S`는 스크롤백까지 찾아요. grep처럼 찾은 게 없으면 종료 코드 1이에요. 대기 중인 claude 화면 1000개에서 표시된 화면 3개를 찾는 데 16~32ms 걸렸어요. CLI로 세션마다 `capture-pane`을 하면 4.2~4.3초였어요. `grep-panes`는 얼린 화면의 사본을 읽어서 화면을 펼치지 않아요. ## 이벤트 플러그인은 화면에 일어나는 일을 **이벤트**로 받을 수 있어요: `session-created`, `session-exited`, `session-renamed`, `output`(읽은 바이트), `activity`(바이트를 복사하지 않고 화면이 바뀌었다는 것만). 출력 이벤트는 모아 읽기와 함께 오므로, 조용한 세션은 약 300ms 단위로 알려요. 아주 큰 출력이 한꺼번에 오면 `output`은 데이터를 빠뜨릴 수 있어요. 모든 바이트가 필요하면 `pipe-pane`을 쓰세요. 자세한 내용은 [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces#플러그인)을 보세요. ## 왜 이렇게 했나요? - **화면은 대부분 조용해요.** 그래서 접어 두면 조용한 화면은 메모리를 거의 쓰지 않아요. claude 작업을 재생하는 세션 400개가 서버 메모리 2.66~2.70MB를 썼어요. 대신 출력이나 읽기가 오면 조금 펼치는 비용이 들어요. - **모아 읽으면 CPU를 아껴요.** claude 세션 400개에서 읽기 간격 100ms일 때 CPU 5.2%, 300ms일 때 3.7%였어요. 화면을 읽기 전마다 밀린 출력부터 읽어서 답은 늘 최신이에요. - **기다리기는 CPU가 조금 들어요.** 기다리는 세션은 바로 읽기 때문에, `wait-pane`은 200ms 폴링보다 서버 CPU가 0.8~1.0%p 높았어요. 그 대신 알아채는 시간이 p50 86~262ms에서 1ms로 줄고, 호스트의 폴링 코드가 사라져요. ## 다음 단계 - [셸에서 에이전트 다루기](/ko/guide/shell): 스크립트에서 쓰는 `send-keys`, `wait-pane`, `capture-pane`. - [명령 레퍼런스: 기다리기와 찾기](/ko/reference/commands#기다리기와-찾기): 모든 옵션. - [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces): 명령이 엔진까지 가는 길. --- # 세션과 서버 이 페이지는 세션이 무엇인지, 어느 프로세스가 세션을 가지는지, 명령이 세션을 어떻게 찾는지 설명해요. 세션을 띄우는 순서는 [시작하기](/ko/guide/getting-started)를 보세요. ```mermaid flowchart TB subgraph clients["명령을 보내는 쪽"] cli["zeropty CLI"] sh["zsh 함수 · bash builtin"] node["client/zeropty.mjs"] att["zeropty attach"] end subgraph server["서버 프로세스: zeropty serve"] eng["엔진: 세션 · 화면 · 명령
스레드 하나, kqueue 하나(Linux는 epoll)"] end subgraph sessions["세션"] s1["work: claude-zeropty"] s2["build: sh"] s3["..."] end clients -->|"~/.zeropty/mux.sock
명령마다 JSON 한 줄"| eng eng --> s1 eng --> s2 eng --> s3 ``` *모든 클라이언트는 Unix 소켓으로 서버 하나와 말하고, 서버의 엔진이 모든 세션을 가져요.* ## 세션은 프로그램 하나예요 **세션**은 프로그램 하나와 그 화면이에요. `new-session`은 명령 하나를 `/bin/sh -c`로, 자기 프로세스 그룹으로 띄워요. 세션마다 화면 하나와 스크롤백이 있어요. 창이나 창 분할은 없어요. | tmux | zeropty | |---|---| | 세션 → 창 → 창 분할 | 세션 = 프로그램 하나 | | 창 분할마다 PTY 하나 | PTY 없음, 세션마다 socketpair 하나 | | `split-window`, `new-window` | 없어요 | | `window_index`, `pane_index` | 스크립트 호환을 위해 늘 `0` | 명령이 없으면 `new-session`은 `$SHELL`을, 그게 없으면 `/bin/sh`를 돌려요. 화면 기본값은 80×24이고, `-s`가 없으면 이름은 지금까지 만든 세션 수(`0`, `1`, ...)예요. 세션의 프로그램은 이런 변수를 받아요: `TERM=xterm-256color`, `COLUMNS`와 `LINES`, `ZEROPTY_SESSION`(세션 이름), `ZEROPTY_COLS`와 `ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE`과 `ZEROPTY_TTY_ID`. 마지막 둘은 shim용이에요. `TMUX`와 `TMUX_PANE`은 지워서, tmux 안에서 띄운 세션도 tmux 안에 있지 않아요. ## 서버와 소켓 **서버**는 엔진을 돌리고 모든 세션을 가지는 프로세스예요. `zeropty` CLI는 명령마다 서버의 **소켓**에 보내요. 기본은 `~/.zeropty/mux.sock`이에요. 다른 경로를 쓰려면 `ZEROPTY_SOCK`을 103바이트 이하로 정하세요. - **누가 띄우나요.** 돌고 있는 서버가 없으면 tmux처럼 `new-session`이 띄워요. `pull-sessions`도 그래요. `zeropty serve`로 직접 띄울 수도 있어요. - **누가 접속할 수 있나요.** 소켓은 0600 권한이라 같은 사용자(와 root)만 접속할 수 있어요. - **엔진 하나, 스레드 하나.** 스레드 하나와 kqueue 하나(Linux는 epoll)가 모든 세션의 출력, 프로세스 종료, 타이머를 처리해요. 세션마다 스레드나 프로세스를 두지 않아요. - **꼭 별도 프로세스일 필요는 없어요.** 같은 엔진을 내 프로그램 안에서 돌릴 수도 있어요. 자세한 내용은 [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces)을 보세요. 소켓에서 답하는 서버가 없으면 명령은 `no server running on ...`과 종료 코드 1로 실패해요. ### 서버의 환경 서버는 tmux처럼 자신을 띄운 프로세스의 환경을 그대로 가져요. 서버를 띄우는 첫 명령, 즉 `new-session`, `pull-sessions`, `zeropty serve` 중 하나예요. 이후 세션은 `new-session`을 실행한 셸의 환경이 아니라 그 환경을 받아요. | 변수를 줄 곳 | 쓰는 것 | |---|---| | 새 세션 하나 | `new-session -e VAR=value` (여러 번 쓸 수 있어요) | | 이후에 만드는 모든 세션 | `set-environment -g VAR value` | | 이후 세션에서 지우기 | `set-environment -g -u VAR` | `restart-server`는 자신을 실행한 셸에서 새 서버를 띄워요. 그러면 이후 세션은 그 셸의 환경을 받고, `set-environment -g`로 정한 값은 사라져요. 실행 중인 세션은 자기 환경을 그대로 가져요. ## 타깃 **타깃**(`-t`)은 세션 하나를 가리켜요. 아래 형태는 모두 세션 `work`예요. ```text =work: =work work work:0 ``` 이름은 **정확히 일치**해야 해요. tmux는 앞부분 일치도 해서 `-t wo`로 `work`에 닿을 수 있지만, zeropty는 없는 세션으로 다뤄요. 스크립트에서는 tmux에서처럼 `=name:`으로 쓰세요. ::: warning zsh와 `=` zsh는 `=word`를 명령 경로로 바꿔요. `-t '=work:'`처럼 타깃을 따옴표로 감싸세요. tmux도 같아요. ::: ## 스크롤백 화면 위로 밀려난 줄은 세션의 스크롤백에 들어가요. 기본으로 500줄을 남기고, `ZEROPTY_HISTORY`로 바꿔요. `capture-pane`은 tmux와 같은 번호 매김으로, 음수 줄 번호로 스크롤백을 읽어요. ```sh [터미널] zeropty capture-pane -p -S -200 -t '=work:' # the screen plus 200 lines of scrollback ``` `grep-panes -S`도 스크롤백까지 찾아요. 자세한 내용은 [화면, 키, 기다리기](/ko/guide/concepts/screens)를 보세요. ## 세션이 끝날 때 | 일 | 어떻게 되나요 | |---|---| | 프로그램이 끝나요 | tmux에서 `remain-on-exit`가 꺼져 있을 때처럼 세션도 끝나요 | | `kill-session -t NAME` | 세션의 프로세스 그룹에 SIGHUP, 2초 뒤에도 살아 있으면 SIGKILL | | `kill-server` | 모든 세션에 SIGHUP. 독립 서버는 함께 끝나고, 임베디드 엔진은 계속 돌아요 | | 엔진을 넣은 프로세스가 끝나요 | 그 세션들도 함께 끝나요(프로그램들은 EOF를 받아요) | | `restart-server` | 아무것도 끝나지 않아요. 새 서버가 모든 세션과 소켓을 넘겨받아요 | `NAME`은 세션 이름으로 바꾸세요. `restart-server`와 `pull-sessions` / `push-sessions`는 실행 중인 세션을 다른 프로세스로 옮겨요. 프로그램은 계속 돌고 pid도 그대로예요. 순서는 [세션 옮기기와 서버 업그레이드](/ko/guide/moving)를 보세요. ## 왜 이렇게 했나요? - **창 없는 tmux 호환.** 에이전트 호스트는 이미 `new-session`, `send-keys`, `capture-pane`으로 tmux를 다뤄요. 이 이름과 인자를 지켜서 대부분의 스크립트에서 `tmux`를 `zeropty`로 바꾸기만 하면 돼요. 창과 창 분할은 사람이 화면을 나눠 쓰려고 있는 것이라 에이전트 호스트에는 필요 없어요. - **세션 하나에 프로그램 하나라 구조가 단순해요.** 타깃은 정확히 화면 하나를 가리켜서 `-t =work:`가 모호할 일이 없어요. 대신 세션 하나를 여러 프로그램으로 나눌 수는 없어요. 세션을 여러 개 띄우세요. - **정확한 이름이라 뜻밖의 일이 없어요.** 앞부분 일치를 하면 줄이거나 잘못 친 이름도 어떤 세션에 닿아서, 의도하지 않은 에이전트에 키가 갈 수 있어요. 자동화에는 정확히 일치하는 편이 안전해요. 대신 앞부분 일치에 기대던 tmux 스크립트는 고쳐야 해요. - **환경은 서버가 가져요.** tmux의 규칙이라 기존 스크립트가 똑같이 동작해요. 대신 셸에서 export한 변수는 `-e`나 `set-environment -g`로 넘기지 않으면 새 세션에 닿지 않아요. ## 다음 단계 - [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal): PTY를 무엇이 대신하는지. - [명령 레퍼런스: 세션](/ko/reference/commands#세션): `new-session`, `kill-session` 등의 모든 옵션. - [환경변수](/ko/reference/environment): `ZEROPTY_SOCK`, `ZEROPTY_HISTORY` 등. - [tmux에서 넘어오기](/ko/guide/from-tmux): 스크립트에서 무엇이 달라지는지. --- # 세션이 터미널을 얻는 방법 이 페이지는 세션이 PTY(가상 터미널) 대신 무엇을 가지는지, 터미널이 필요한 프로그램이 어떻게 그래도 돌아가는지, 보낸 키를 엔진이 어떻게 다루는지 설명해요. vim이나 claude를 띄우는 순서는 [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs)를 보세요. ```mermaid flowchart LR subgraph tmux["tmux · 창 분할마다 PTY"] direction TB tc["send-keys"] --> ts["tmux 서버"] ts -->|"master 쪽"| pty["커널 PTY
라인 디시플린 · 에코 · 시그널"] pty -->|"slave 쪽: stdin, stdout, stderr"| tp["프로그램"] end subgraph zp["zeropty · socketpair"] direction TB zc["send-keys"] --> ze["엔진
라인 디시플린 · 에코 · 시그널"] ze -->|"socketpair"| zpg["프로그램
(+ zeropty-tty로 넣은 shim)"] end tmux ~~~ zp ``` *tmux는 키, 에코, 시그널을 커널 PTY에 맡기고, zeropty는 그 일을 엔진에서 하고 socketpair로 프로그램과 말해요.* ## PTY 대신 socketpair 세션마다 터미널은 socketpair 하나예요. 프로그램의 stdin, stdout, stderr가 모두 그 한쪽 끝이에요. tmux에서 PTY 하나인 것처럼요. 다른 쪽 끝은 엔진이 가지고, 거기에 키를 쓰고 출력을 읽어요. | | tmux | zeropty | |---|---|---| | 세션마다 터미널 | 커널 PTY 하나 | socketpair 하나 | | 세션 한도 | macOS PTY 한도: 기본 511개, 최대 999개 | PTY 한도 없음, 세션 1000개 확인 | | 세션 400개일 때 쓰는 PTY | 435개 | 35개 (시스템 기본값, 1000개에서도 같아요) | | Enter, `C-c`, 에코, 줄 편집 | 커널 라인 디시플린 | 엔진이 흉내 내요 | | 출력 버퍼 | PTY 버퍼 | 세션마다 64KB 소켓 버퍼 | 남는 한도도 있어요. macOS는 사용자당 프로세스 수(`kern.maxprocperuid`)를 제한하고, claude 세션 하나는 프로세스가 4개쯤이에요. ## "터미널이 아니에요" 소켓은 터미널이 아니라서 세션 안에서 `isatty()`는 false를 돌려줘요. vim, less, python REPL, claude처럼 이걸 확인하는 프로그램은 그냥 띄우면 터미널이 아니라고 판단해요. `sh`, `cat`, `python3` 스크립트, 빌드 명령처럼 터미널이 없어도 되는 프로그램은 그대로 돌아요. ## shim과 zeropty-tty **shim**(macOS는 `shim.dylib`, Linux는 `shim.so`)은 프로그램 안에 들어가 터미널 호출에 터미널처럼 답하는 작은 라이브러리예요. **`zeropty-tty`**는 `DYLD_INSERT_LIBRARIES`(macOS)나 `LD_PRELOAD`(Linux)로 shim을 넣어 프로그램을 띄우는 스크립트예요. ```sh [터미널] zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt" ``` | shim이 답하는 호출 | 프로그램이 받는 것 | |---|---| | `isatty` | 세션의 소켓에 대해 "터미널이에요" | | `tcgetattr`, `tcsetattr` | 터미널 모드. 모드가 바뀌면 엔진에 알려요 | | `ioctl(TIOCGWINSZ)` | `ZEROPTY_SIZE_FILE`에서 읽은 창 크기 | | `tcgetpgrp`, `tcsetpgrp` | 포그라운드 프로세스 그룹. 바뀌면 엔진에 알려요 | | `open("/dev/tty")` | 세션의 소켓. 읽기, 쓰기, 둘 다 돼요 | | `readpassphrase`, `getpass` | 친 내용을 에코하지 않는 입력 프롬프트 | 터미널로 치는 건 세션 자신의 소켓뿐이에요. stdout을 파이프로 넘기거나(`zeropty-tty prog | cat`) stdin이 `/dev/null`인 프로그램은 tmux에서처럼 거기서 터미널을 보지 못해요. 세션 밖에서는 shim이 아무것도 답하지 않아요. 프로그램이 터미널 모드를 바꾸면, shim이 그 사실을 화면에는 나오지 않는 OSC(operating system command) 시퀀스로 출력 안에 끼워 보내요. 그래서 엔진은 키를 다르게 다뤄야 하는 정확한 지점을 알아요. 사람이 다른 크기의 터미널로 붙을 때처럼 창 크기가 바뀌면, 엔진이 크기 파일을 다시 쓰고 SIGWINCH를 보내요. ### shim이 들어가지 못하는 프로그램 macOS는 `/usr/bin` 같은 SIP 보호 바이너리와 hardened runtime 서명 바이너리에 shim을 넣지 못하게 해요. 그래서 `zeropty-tty`는 이런 프로그램을 서명만 바꾼 사본으로 실행해요. 사본은 `~/.zeropty/tty/`에 두고, 원본이 바뀌면 다시 만들어요. - 사본에는 원본의 권한이 없어서 `top`, `ps`는 이렇게 돌릴 수 없어요. - `/usr/bin/env`를 거치는 스크립트는 shim을 잃어요. 인터프리터를 직접 `zeropty-tty`로 띄우세요. - 세션 안 셸에서 친 `/usr/bin` 프로그램은 shim 없이 돌아요. 터미널이 필요하면 `zeropty-tty`로 띄우세요. ### claude-zeropty `claude-zeropty`는 `zeropty-tty claude`를 실행하는 작은 래퍼예요. `PATH`에 있는 `claude`를 써요. 다른 claude를 쓰려면 `ZEROPTY_CLAUDE_REAL`을 정하세요. ## 라인 디시플린 **라인 디시플린**은 입력한 키를 프로그램이 읽는 내용으로 바꾸는 층이에요. PTY에서는 커널이 해요. zeropty에서는 엔진이 키를 보낼 때 하고, 같은 키를 tmux와 대조했어요. 새 세션은 새 PTY와 같은 cooked 모드로 시작해요. | 모드 | 키가 어떻게 되나요 | |---|---| | cooked (기본값) | Enter가 줄바꿈이 돼요. 입력을 줄 단위로 모으고 `BSpace`, `C-u`, `C-w`, `C-v`로 편집할 수 있어요. 입력한 글자가 에코돼요. 빈 줄의 `C-d`는 입력 끝이에요 | | raw (claude, vim, python REPL, shim이 알려 준 뒤) | 키가 그대로 가요 | shim 없는 프로그램이 raw 모드를 원하면 `\033]zeropty-tty;0\007`을 출력하면 돼요. `stty raw -echo`에 해당해요. ### 포그라운드 작업에 가는 시그널 cooked 모드에서 `C-c`, `C-\`, `C-z`는 프로세스 그룹에 시그널로 가요. 세션의 그룹이거나, job control이 있는 셸(zsh, `bash -i`)이 터미널을 넘겨준 작업의 그룹이에요. 셸의 `tcsetpgrp`는 shim을 거쳐 엔진에 전달되므로 셸은 shim과 함께 돌아야 해요(`zeropty-tty zsh`). 작업 자체는 `/usr/bin` 프로그램을 포함해 어떤 프로그램이든 돼요. 세션 프로그램 아래에 멤버가 있는 프로세스 그룹만 받아들여요. 0.1.5 전에는 이 키들이 작업이 아니라 셸로 가서, zsh에서 친 `sleep 30`을 멈출 수 없었어요. 업그레이드 전에 띄운 셸은 예전 shim을 그대로 가지고 있으니 다시 띄우세요. 자세한 내용은 [변경 기록](/ko/guide/changelog)을 보세요. ### PTY와 다른 점 - **`C-d`는 입력을 아예 닫아요.** 소켓에는 EOF를 한 번만 알리는 방법이 없어서, 빈 줄에서 `C-d`를 보내면 그 뒤로 세션은 입력을 받지 않아요. 출력은 계속 나와요. 끝낼 생각이 아니라면 `C-c`나 프로그램 자체의 종료 명령을 보내세요. - **`O_NONBLOCK`이 함께 걸려요.** PTY처럼 stdin, stdout, stderr가 파일 하나예요. 프로그램이 그중 하나에 `O_NONBLOCK`을 걸면 셋 모두에, 세션에서 그 뒤에 실행하는 명령에도 걸려요. ## 왜 이렇게 했나요? - **PTY 한도가 없어요.** tmux는 창 분할마다 PTY를 하나씩 열어서 기본 511개, 최대 999개에서 멈춰요. 에이전트 호스트에는 세션이 수백~수천 개 필요해서 zeropty는 PTY를 아예 쓰지 않아요. - **파이프가 아니라 socketpair.** macOS 파이프는 약 250개가 커지면 나머지가 512바이트로 묶여요. 세션 1000개에서 출력의 41%가 프로그램 안에 묶여 있었어요. 세션마다 64KB 소켓 버퍼를 따로 주자 다 흘러요. 대신 zeropty가 읽지 않는 동안 세션마다 최대 64KB가 커널 버퍼에 기다려요(1000개면 64MB). - **엔진이 라인 디시플린을 맡아요.** 에코와 시그널을 엔진에서 하면 cooked 세션에서 키마다 10µs쯤 더 들어요. claude 같은 raw 모드 세션에는 추가 작업이 없어요. - **대가는 shim이에요.** 터미널이 필요한 프로그램은 `zeropty-tty`로 띄워야 하고, `top` 같은 권한 있는 시스템 프로그램은 그렇게 돌릴 수 없어요. Linux에서 정적 링크된 프로그램(대부분의 Go 바이너리)은 shim을 불러오지 않아요. ## 다음 단계 - [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs): vim, zsh, claude를 띄우는 순서. - [화면, 키, 기다리기](/ko/guide/concepts/screens): 출력이 어떻게 되는지. - [키 이름](/ko/reference/keys-and-formats#키-이름): `send-keys`가 아는 모든 키. - [설계](/ko/guide/design#1-pty-대신-소켓): shim과 라인 디시플린을 더 깊이. --- # Benchmarks Every measurement is here. Items that fall short of 10x (⛔), the reasons why, and things we tried and did not ship are all recorded as they are. The README lists only the items whose whole range is 10x or better under [a single rule](/guide/benchmarks#how-multipliers-are-computed). ## Summary: all items **Of the 15 items measured, 11 are 10x or better than tmux across their whole range.** The remaining 4 (⛔) have a low end of 1.6\~8.9x. What sets those lower bounds is described in [Status of the 10x goal](/guide/benchmarks#status-of-the-10x-goal-all-items) below. All values are real measurements taken alternately with tmux on the same machine (Mac mini M1). One of the 11 crossed 10x only because tmux was slower: 5.4MB of output until it is visible on screen was borderline (9.9\~11.7×) on 2026-10-18 and is 10.5\~12.5× on 2026-10-21. zeropty took the same time both days (14\~16ms, then 15\~16ms), and tmux went from 159\~164ms to 169\~188ms on a busier machine. It is ✅ under [the rule](/guide/benchmarks#how-multipliers-are-computed) but is not in the README. | What | tmux | zeropty | Multiplier | Reduction | |---|---|---|---|---| | CPU, 400 real claude workloads | 54.30\~54.91% | 3.51\~3.84% | **14.1\~15.6×** | **−92\~93%** | | Memory, 400 real claude workloads (standalone server) | 32.4\~32.5MB | 2.66\~2.70MB | **12.0\~12.2×** | **−91%** | | Memory, 400 real claude workloads (embedded, polled) | 32.4\~32.5MB | 1.63\~1.64MB | **19.7\~19.9×** | **−94%** | | Memory, 400 idle (embedded, polled) | 8.0MB | 0.45\~0.46MB | **17.3\~17.7×** | **−94%** | | Memory, 200 idle (embedded + server plugin, also reachable via CLI and attach) | 5.0\~5.1MB | 388\~389KB | **12.8\~13.1×** | **−92%** | | Memory, 400 redrawing screens | 251.2\~251.4MB | 1.71\~1.74MB | **144\~147×** | **−99%** | | Memory, 400 idle (standalone server) | 8.0MB | 1.25\~1.27MB | 6.2\~6.4× ⛔ | −84% | | `send-keys`, running a tmux command vs zeropty socket (Node) | 9.03\~9.56ms | 0.24\~0.26ms | **34.7\~39.8×** | **−97%** | | `send-keys`, tmux control mode (`-C`) vs zeropty embedded (Node) | 0.42\~0.49ms | 0.06\~0.07ms | 6.0\~8.1× ⛔ (per run 7\~8× †) | −83\~87% | | `send-keys`, tmux control mode (`-C`) vs zeropty socket (Node) | 0.42\~0.49ms | 0.24\~0.26ms | 1.6\~2.0× ⛔ (per run 1.7\~2.0× †) | −38\~51% | | Keystroke → screen, tmux control mode (`-C`) vs zeropty embedded (Node) | 0.89\~0.98ms | 0.09\~0.10ms | 8.9\~10.8× ⛔ (per run 9\~10× †) | −88\~90% | | `has-session` from the shell, tmux vs zeropty zsh function | 6.04\~6.19ms | 0.11\~0.17ms | **35.5\~56.2×** | **−97\~98%** | | `send-keys` from the shell, tmux vs zeropty bash builtin | 6.02\~6.31ms | 0.043\~0.048ms | **125\~146×** | **−99%** | | 5.4MB of output, until the program finishes | 166\~176ms | 3.0\~4.6ms | **36.0\~58.6×** | **−97\~98%** | | 5.4MB of output, until it is visible on screen | 169\~188ms | 15\~16ms | **10.5\~12.5×** | **−90\~92%** | | Session count limit | 511 (default PTY limit, max 999) | 1000 verified, no PTY limit | – | – | ### How multipliers are computed Every multiplier and reduction in this document follows a single rule. - **They are computed from the ends of the value ranges shown in the tables.** The low end is the fastest (smallest) tmux value ÷ the slowest (largest) zeropty value, and the high end is the reverse. A single value gives a single multiplier. Per-run pairings are not kept, so anyone can recompute the same multipliers from the tables alone. - **Truncate, never round up.** Below 100x, digits past the first decimal place are dropped; at 100x and above, everything past the decimal point is dropped (13.96 → 13.9, 141.6 → 141). Reductions are also truncated to whole numbers. When units are mixed (MB and KB), 1MB = 1000KB is used and the smaller multiplier is reported. - **The 10x verdict uses this same rule.** An item is ✅ and goes into the README only if the low end of its range is 10x or more. If the low end is under 10x, it is ⛔ even if the high end exceeds 10x. - **Values shown for reference only: multipliers printed directly by `bench/latency.mjs` (†).** For each run, the script prints the multiplier between that run's unrounded p50 values (rounded to whole numbers for the embedded column and to one decimal place for the socket column). The three right-hand columns of the Node latency table and the synchronous client multiplier are the range over those three runs. Per-run p50 values were not kept, so these cannot be recomputed from the tables; they are not used for the verdict and are only shown alongside, marked †. For example, embedded `send-keys` (vs running tmux) is 140\~151× per run, and 128\~159× when divided using the ends of the value ranges. Keystroke → screen (vs tmux -C, embedded) reached 10× in some runs (9\~10× per run), but the ends of the value ranges give 8.9\~10.8×, so it is ⛔. > The numbers in this document are always the latest measurements. Any change that moves a number updates this document and the README tables in the same commit. **Measurement environment** | Item | Value | |---|---| | Date | 2026-10-21 (server memory and CPU, embedded memory with and without the server plugin, Node latency three times, shell, bulk output, `wait-pane`, `grep-panes`), 2026-10-19 (C client and server plugin latency, polled event loop blocking) | | Commit | `9694aa3` with the `bench/` scripts of this change (2026-10-21 measurements) | | Machine | Apple M1 (4 P-cores + 4 E-cores), 16GB | | OS | macOS 26.6.2 | | Compared against | tmux 3.7c | **Measurement conditions** - Session settings: 220×80, 2000 lines of scrollback. tmux uses the same size, and 2000 lines is tmux's default scrollback. - Memory: the average of 10 footprint samples taken every 2 seconds over 20 seconds. - CPU: divided by the actual elapsed time of the same interval. - Real workload replay: session i starts at the (i × 0.618 mod 1) point of the recording, so the load is the same across runs and servers. - Repeating the same setup varies memory by about ±1.5% and CPU by about ±5% (the server often runs on an efficiency core). - On 2026-10-21 other programs were running on the machine (load average about 2.9), so tmux and zeropty were both slower than on earlier dates in the latency and shell measurements. Every pair was still measured alternately under the same load, and the multipliers compare those pairs. ## Memory and CPU: standalone server (`bench/suite.sh`) | Load | Sessions | tmux memory | zeropty memory | Multiplier | tmux CPU | zeropty CPU | Multiplier | |---|---|---|---|---|---|---|---| | Idle (one claude screen printed, then stopped) | 200 | 5.0\~5.1MB | 1.20MB | 4.1\~4.2× ⛔ | 0% | 0% | – | | | 400 | 8.0MB | 1.25\~1.27MB | 6.2\~6.4× ⛔ | 0% | 0% | – | | Screen redraw (31 lines redrawn every second) | 200 | 121.0\~121.3MB | 1.57\~1.61MB | **75.1\~77.2×** | 18.21\~18.31% | 1.02\~1.12% | **16.2\~17.9×** | | | 400 | 251.2\~251.4MB | 1.71\~1.74MB | **144\~147×** | 28.71\~29.79% | 1.85\~2.09% | **13.7\~16.1×** | | Whole claude transcript dump (15KB, every 2 seconds) | 200 | 7.3\~7.7MB | 1.71\~1.73MB | 4.2\~4.5× ⛔ | 26.74\~26.83% | 1.51\~1.61% | **16.6\~17.7×** | | | 400 | 12.5\~13.6MB | 1.95\~2.30MB | 5.4\~6.9× ⛔ | 42.29\~42.82% | 2.96\~2.97% | **14.2\~14.4×** | | Real claude workload replay (`tests/streams/claude2.trace`) | 200 | 17.4MB | 1.65\~1.66MB | **10.4\~10.5×** | 36.04\~36.67% | 2.24\~2.29% | **15.7\~16.3×** | | | 400 | 32.4\~32.5MB | 2.66\~2.70MB | **12.0\~12.2×** | 54.30\~54.91% | 3.51\~3.84% | **14.1\~15.6×** | | Idle | 1000 | Not possible (PTY limit) | 1.38\~1.39MB | – | Not possible | 0% | – | | Real claude workload replay | 1000 | Not possible (PTY limit) | 4.65MB | – | Not possible | 6.69\~7.10% | – | Measured on 2026-10-21, `bench/suite.sh` twice; ranges are the two runs. MB here is footprint KB ÷ 1000. - **The 200-session real workload now passes 10x (8.4\~9.0× on 2026-10-19).** zeropty went from 1.92\~2.05MB to 1.65\~1.66MB, while tmux stayed at 17.4MB. The CPU of the 200-session transcript dump went from borderline (9.9\~10.2×) to 16.6\~17.7×, as tmux used more CPU (17.9% → 26.74\~26.83%) and zeropty less (1.75\~1.80% → 1.51\~1.61%). We did not look for the cause of either. **Fixed so that all output of 1000 sessions flows (2026-10-18).** The previous measurement (4.66%) was taken while only 59% of the output was flowing. The cause was macOS pipes. - macOS hands out pipe buffers from a system-wide 16MB. Once about 250 pipes have grown to 64KB, every remaining pipe gets only **512 bytes** (verified with 3000 pipes created by hand). - A 512-byte pipe cannot be told apart through `fstat` or kevent (both report 16KB). Programs in such sessions wrote only 512 bytes per 300ms read and then waited. - After session output was switched to **socketpair** (64KB buffer, separate per session), the amount the server read in the 1000-session replay went from 59% to **102%** of the recording rate. For 200 and 400 sessions it was 101% both before and after. - So the CPU figure for 1000 sessions reflects processing more output. - **The cost:** when zeropty stops reading, up to 64KB per session accumulates in the kernel socket buffers (mbufs). For 1000 sessions that is 64MB (this machine's mbuf pool is 256MB). With pipes, the system-wide cap was 16MB. **Items marked ⛔ fall short of 10x because of the lower bound of a server process.** Even a zeropty server with 1 session is 1.09MB (2026-10-19). A C program that does nothing is 0.87MB, a `perl` process 1.36MB, and an empty tmux server 2.2MB. - Even the 0.865MB of an empty C program is mostly the operating system's share (broken down with `footprint`): 209KB of kernel page tables (XNU counts these in the footprint too), about 200KB of system malloc metadata, about 240KB of data modified by libraries (`__DATA_DIRTY`, `__DATA`, shared cache), plus the stack and malloc regions. Link options (`-no_fixup_chains`, `-dead_strip`, a small stack) moved things only at noise level (±16KB), and Apple's own `/bin/sleep` is 832KB. - **What could be reduced was the system malloc configuration (applied 2026-10-19).** The server re-executes itself with `MallocNanoZone=0 MallocMaxMagazines=1 MallocSpaceEfficient=1 MallocMediumZone=0` (libmalloc reads these only at startup). Screens are handled by mimalloc and system malloc only serves libSystem's share, so it is fine to shrink the regions and per-CPU caches to suit a single thread. Every load dropped by 144\~240KB (1 session 1233 → 1089KB, 200 idle 1.39\~1.44 → 1.23MB, 400 real workloads 2.96\~3.04 → 2.72\~2.82MB, alternated 2 times). CPU had the same distribution (400 real workloads, alternated 3 times: 1.47/1.53/4.01% vs 1.47/3.85/3.86%; it swings between two values depending on which core the server runs on). These settings are not passed on to programs running in sessions. - zeropty's own share is the 384KB held by mimalloc. The actual heap is 8KB, but long-lived small objects are scattered across different 16KB pages by size class, and each page is held in full. mimalloc settings (eager commit, arena size) had no effect. `MallocNanoZone=0` alone saves only 16KB; it takes all four settings above together. For example, for 400 idle sessions to reach 10x, it would need to be 0.80MB or less, which is smaller than an empty process. **Could a server written in another language get past this?** We built empty servers and measured them (2026-10-18, Mac mini M1). | Empty server | Memory | |---|---| | Static binary using only system calls (no libSystem) | Cannot run: the Apple Silicon macOS kernel SIGKILLs it immediately | | C, `pause()` only / kqueue and socket server | 0.865MB / 0.865MB | | Rust, empty `main` | 0.93MB | | **zeropty empty server** | **1.15MB** | | Go, socket server | 2.59MB | Rewriting in C could save about 0.29MB on an empty server. But an empty C process (0.865MB) is already larger than the 10x bar for 400 idle sessions (0.80MB). So **it is impossible with a separate process, in any language.** The way past this lower bound is **embedded mode**, below. With no separate process, there is no process lower bound. **Memory added per session when going from 200 to 400 sessions (standalone server):** | Load | tmux | zeropty | Multiplier | |---|---|---|---| | Idle | 14.9\~15.0KB | 0.24\~0.32KB | **46.5\~62.5×** | | Screen redraw | 649\~652KB | 0.54\~0.83KB | **781\~1207×** | | Whole claude transcript dump | 23.8\~31.8KB | 1.1\~3.0KB | 7.9\~28.9× ⛔ | | Real claude workload | 74.8\~75.3KB | 5.0\~5.3KB | **14.1\~15.0×** | Computed from the footprints in KB of the two runs (2026-10-21), pairing the ends of the ranges as everywhere else. The transcript dump is ⛔ because zeropty's 400-session footprint differed by 352KB between the two runs (1948KB and 2300KB); it was 13.2× on 2026-10-19. **PTY usage:** with 400 sessions, tmux uses 435 and zeropty uses 35. The 35 is the system baseline, so it stays the same even at 1000 sessions. ## Memory: embedded (the engine inside your process, `bench/embed_host.c`) We embedded the engine in a C program that does nothing else, and measured the footprint difference between before the engine is started and after the sessions are up. Hosts like Node, whose own heap moves by several MB, cannot be measured this precisely, so a C host is used. The engine can run in two modes. In **engine thread** mode (`zeropty_open`, Node `{ polled: false }`), the engine has its own thread. In **polled** mode (`zeropty_open_polled`, the Node default), there is no thread and the host's event loop drives the engine. Values are the range of two measurements of each mode on 2026-10-21, and the tmux server column is the standalone server table above. | Load | Sessions | tmux server | zeropty engine thread | Multiplier | zeropty polled | Multiplier | |---|---|---|---|---|---|---| | Idle | 200 | 5.0\~5.1MB | 0.68\~0.69MB | 7.2\~7.5× ⛔ | **0.39MB** | **12.8\~13.0×** | | | 400 | 8.0MB | 0.74MB | **10.8×** | **0.45\~0.46MB** | **17.3\~17.7×** | | Real claude workload replay | 200 | 17.4MB | 1.65\~1.66MB | **10.4\~10.5×** | **0.95\~1.32MB** | **13.1\~18.3×** | | | 400 | 32.4\~32.5MB | 2.25\~2.33MB | **13.9\~14.4×** | **1.63\~1.64MB** | **19.7\~19.9×** | - **Just starting the engine thread costs +308\~309KB.** That is the thread's allocator heap, stack and page tables. Polled is +180\~181KB. - **Polled 200 idle went from 0.31MB (16.4×, 2026-10-18) to 0.39MB (12.8\~13.0×).** Starting the engine went from +149KB to +180\~181KB, and the 200 sessions from +160KB to +208KB. We have not found which change since then added it. - **Polled also has a smaller per-session share.** All allocations are gathered in a single heap on the host thread (200 idle: 368\~384KB → 208KB). - **The cost of polled:** the host thread parses session output (\~3.5% of one core with 400 real workloads). All commands must be called from that thread (Node has a single JS thread anyway, so this is no restriction; use `{ polled: false }` only when calling from worker threads). - **Bulk output is parsed in pieces.** Polled reads output chunks 256KB at a time, at most 2ms per turn, and then gives the turn back to the host loop. With 5.4MB of output coming into 200 busy sessions, the longest the Node event loop was blocked went from 11\~32ms to 3\~6ms (`bench/loop_block.mjs`, alternated 3 times). Time until everything is on screen and memory are unchanged. The engine-thread mode also blocked for up to 30ms, from contention for the CPU. - Per session: 0.24\~0.32KB idle, 3.0\~3.4KB for real workloads (engine-thread mode, 200 → 400 sessions). - **Trade-off:** screens that have been quiet for more than 10 seconds are compressed with zstd and kept in an unnamed temporary file. They leave process memory but stay in the OS page cache (the OS reclaims it when needed). `ZEROPTY_COLD=memory` keeps them in memory instead. **With the server plugin on (`listen`, `bench/embed_host.c ... polled `, 2026-10-21, on and off alternated 2 times).** We measured the same C host with and without the socket open. | Load | Sessions | tmux server (table above) | No plugin | Plugin on | vs tmux server (on) | |---|---|---|---|---|---| | Engine only | 0 | – | +180\~181KB | +180\~181KB | – | | Idle | 200 | 5.0\~5.1MB | 388KB | 388\~389KB | **12.8\~13.1×** | | Real claude workload replay | 200 | 17.4MB | 949\~1322KB | 973\~1248KB | **13.9\~17.8×** | | | 400 | 32.4\~32.5MB | 1630\~1636KB | 1395\~1740KB | **18.6\~23.2×** | - **The plugin itself now adds 0\~1KB** (+16\~18KB on 2026-10-19: one socket and the connection list). In the same period polled's start cost rose by about 31KB, so the engine may now set up at start what `listen` used to add; we have not checked. The differences under the real workload are within noise that flips up and down from run to run. - **So you can use the CLI, attach and socket clients while keeping embedded-level memory.** The same 200 idle sessions on a standalone server take 1.20MB (4.1\~4.2× ⛔). The difference is the share of one server process. - **Caveat:** these values are increments assuming the host program exists anyway (excluding the host itself, \~1.08MB). If you start a host just to hold sessions, that is the same as a standalone server. ## Latency (`node bench/latency.mjs 200`) We measured four ways a Node program can talk to a session server, side by side. | Method | Description | |---|---| | tmux | Run tmux for each command via Node `execFile` (the common way programs call tmux) | | tmux -C | Control mode: keep one tmux client open and send commands over stdin (the fastest way to use tmux) | | zeropty | Keep one connection and send requests directly to the server socket (`client/zeropty.mjs`) | | zeropty embedded | Embed the engine in the Node process and call it as a function (`client/embedded.mjs`) | Each server ran 200 busy sessions, and requests were sent **alternately**. 40 times per item; values are the range of p50 (ms) over three runs on 2026-10-21. The embedded column is polled mode, the Node default. The three multiplier columns on the right are the range of the multipliers `bench/latency.mjs` printed per run (†). The 10x verdict uses the next table, with multipliers divided using the ends of the value ranges ([How multipliers are computed](/guide/benchmarks#how-multipliers-are-computed)). | Item | tmux | tmux -C | zeropty | zeropty embedded | Embedded vs tmux † | Embedded vs tmux -C † | zeropty vs tmux -C † | |---|---|---|---|---|---|---|---| | `capture-pane` (busy session) | 9.31\~9.90 | 0.55\~0.63 | 0.25\~0.30 | 0.10\~0.17 | **57\~93×** | 3\~5× ⛔ | 1.8\~2.5× ⛔ | | `capture-pane -e -S -200` (quiet session) | 9.37\~10.14 | 0.45\~0.60 | 0.23\~0.27 | 0.06\~0.07 | **134\~172×** | 7\~10× ⛔ | 2.0\~2.3× ⛔ | | `send-keys` | 9.03\~9.56 | 0.42\~0.49 | 0.24\~0.26 | 0.06\~0.07 | **140\~151×** | 7\~8× ⛔ | 1.7\~2.0× ⛔ | | Keystroke → visible on screen | 24.62\~25.24 | 0.89\~0.98 | 0.35\~0.43 | 0.09\~0.10 | **250\~268×** | 9\~10× ⛔ (8.9\~10.8× by value range) | 2.1\~2.8× ⛔ | These are multipliers for the same p50 ranges, computed with [the single rule](/guide/benchmarks#how-multipliers-are-computed) (divide using the ends of the value ranges, truncate). The 10x verdict uses this table. | Item | zeropty vs tmux | Embedded vs tmux | Embedded vs tmux -C | zeropty vs tmux -C | |---|---|---|---|---| | `capture-pane` (busy session) | **31.0\~39.6×** | **54.7\~99.0×** | 3.2\~6.3× ⛔ | 1.8\~2.5× ⛔ | | `capture-pane -e -S -200` (quiet session) | **34.7\~44.0×** | **133\~169×** | 6.4\~10.0× ⛔ | 1.6\~2.6× ⛔ | | `send-keys` | **34.7\~39.8×** | **128\~159×** | 6.0\~8.1× ⛔ | 1.6\~2.0× ⛔ | | Keystroke → visible on screen | **57.2\~72.1×** | **246\~280×** | 8.9\~10.8× ⛔ | 2.0\~2.8× ⛔ | - **The embedded columns are polled mode; engine-thread mode is at the same level.** Measured alternately, twice each, on 2026-10-21, polled vs engine thread p50: busy-session capture 0.08\~0.09 vs 0.08\~0.11, quiet-session capture 0.06\~0.07 vs 0.05\~0.07, `send-keys` 0.06 vs 0.06, keystroke→screen 0.10 vs 0.08\~0.09. So the mode does not explain why embedded `send-keys` went from 0.04\~0.06ms (2026-10-18, engine thread) to 0.06\~0.07ms; the machine was busier, and we have not checked further. - **Embedded `send-keys` (vs tmux -C) went from 10\~11x per run to 8\~10x (†, ⛔) when the line discipline was added (2026-10-18), and is 7\~8x per run on 2026-10-21.** The measured session (`cat`) is now in cooked mode, like a PTY. That adds one mode-check read and echo handling per key (about 10µs per key). tmux handles the same echo too, but on the server side, after send-keys has run. Raw-mode sessions (claude) get no extra work (`examples/sendkeys_bench.rs`, same as before the change). The mode-check read could be removed by having the shim report the mode through shared memory instead of output, but we did not ship that. The only gain would be about 8µs per key in cooked sessions (25ms interval, cooked 22.9µs vs raw 14.7µs), and claude, the real target, is already raw. - **We broke down why embedded `capture-pane` (busy session) swings so much (5\~12x per run on 2026-10-18, 3\~5x on 2026-10-21; broken down on 2026-10-18, 200 busy sessions).** - Capturing the same screen back to back takes 5.9µs. Called at 25ms intervals, rendering alone takes 27µs. That is due to the clock and caches of a CPU waking up from idle. Measuring with 1 session gave the same result, and raising the QoS of the calling thread made almost no difference. - For a busy session, capture parses on the spot the output collected over 300ms. That is 15\~30µs at p50, and up to 0.5ms if 8\~12KB has piled up. If the engine thread is reading another session, capture waits for the lock (p99 up to 0.5ms). - Caching the capture result of an unchanged screen would remove rendering even in the cold state, but we did not ship it. It is about 17KB per screen, so 1000 sessions would add 17MB. - Reading recently captured sessions immediately would remove this part, but we did not ship it. For a polling host, the polling interval (hundreds of ms) dominates, so there is no perceptible gain, and CPU goes up (immediate reads: +0.8%p for 200 sessions). For cases that need to know quickly, there is `wait-pane`. - **Why the multipliers swing:** tmux -C is 0.4\~0.6ms and embedded is around 0.05ms, so a 0.01ms difference moves the multiplier by 2\~3. That is why ranges are given rather than a single value. Busy-session capture includes the cost of first reading the output that has piled up, then drawing the screen. - **The zeropty socket method is 31.0\~72.1x faster than running tmux per command** (first column of the second table above). - **The socket method (zeropty) is 1.6\~2.8x vs tmux -C (⛔, last column of the second table above; 1.7\~2.8× † per run).** The Unix socket round trip in Node is itself the lower bound (an echo server that does nothing takes 0.33ms). - **Measured from a C program, the ratio is 1.7\~3.3x (`bench/latency_c.c`, 2026-10-18).** Taking Node out makes tmux -C faster too. | From a C client (p50) | tmux -C | zeropty socket | Multiplier | |---|---|---|---| | `send-keys` | 22\~35µs | 10.5\~12.5µs | 1.7\~3.3× ⛔ | | `capture-pane` (busy session) | 40µs | 13\~15µs | 2.6\~3.0× ⛔ | Both bottom out at one round trip between processes. Reaching 10x vs tmux -C would require 3.5µs or less, but a single socket round trip (two context switches) costs more than that. So changing languages does not help, and only embedded mode, which removes the round trip, gets past 10x. - **The tables above were measured with requests sent back to back, without pauses.** In practice there are idle gaps between requests. So we measured again with an idle interval before each request (`latency_c ... 300 `, 2026-10-19, against a claude replay session rather than an idle `cat` session). With pauses, both get slower and the multiplier shrinks to 1.7x. | C client, p50 | tmux -C | zeropty socket | Multiplier | |---|---|---|---| | `send-keys`, back to back | 26.1µs | 9.3µs | 2.8× ⛔ | | `send-keys`, after 1ms idle | 65.5µs | 36.7µs | 1.7× ⛔ | | `send-keys`, after 25ms idle | 99.2µs | 56.4µs | 1.7× ⛔ | | `capture-pane`, after 25ms idle | 108.2µs | 62.3µs | 1.7× ⛔ | - **The server plugin (a socket opened by an embedded host) is as fast as a standalone server.** `bench/listen_host.c` (a polled engine that opens a socket) and `zeropty serve` were measured alternately, 2 times, with the same C client (2026-10-19). The machine was slower that day, so tmux -C also came out about 2x the table above; the multipliers are similar. | C client, p50 | tmux -C | Standalone server | Server plugin | |---|---|---|---| | `send-keys`, back to back | 26.6\~44.6µs | 12.0\~20.4µs | 12.4\~22.2µs | | `capture-pane`, back to back | 41.5\~74.1µs | 14.6\~27.3µs | 14.2\~28.7µs | | `send-keys`, after 25ms idle | 224\~252µs | 120\~124µs (1.8\~2.1×) | 126\~130µs (1.7\~2.0×) | | `capture-pane`, after 25ms idle | 297\~360µs | 143\~149µs (1.9\~2.5×) | 148\~160µs (1.8\~2.4×) | With pauses, the plugin is 3\~8% slower. The cause has not been confirmed yet. The polled host waits on the engine fd with `poll` and then reads the kqueue once more in `zeropty_process`, so there is one extra system call. - **With pauses, the floor is not the "channel" but the cost of waking a sleeping core (2026-10-19, measured directly in C).** We measured a round trip in which two processes exchange 1 byte, for each method. Back to back, the methods differ at 0.7\~10µs, but after even 1ms idle they are all 12\~21µs, and after 25ms idle 28\~34µs. Only spinning, which keeps the core running, gets 0.08µs, and it uses 100% of a core. | Round-trip method (p50) | Back to back | After 1ms idle | After 25ms idle | |---|---|---|---| | socketpair (what zeropty uses) | 9.7µs | 17.6µs | 33.6µs | | pipe | 5.6µs | 12.2µs | 28.2µs | | Shared memory + `__ulock` wait | 6.5µs | 20.5µs | 28.2µs | | Shared memory + 200µs spin, then wait | 0.7µs | 13.3µs | 28.3µs | | `mach_eventlink` (the kernel hands off directly to the peer thread) | 2.1µs | 14.0µs | – | | Shared memory + continuous spin | 0.08µs | 0.08µs | (100% of a core) | tmux -C takes 99µs after 25ms idle, so 10x would be 9.9µs, which is below this floor (28\~34µs). So 10x under the with-pauses condition is impossible with a separate process. - **Processing time inside the server also grows when things are cold.** For `has-session` sent after 25ms idle, the time from the server receiving the request to writing the reply was 18.5µs (2.4µs when sent back to back). Socket read, JSON parsing, command execution and reply write all slowed down evenly, by 6\~7x. It is not any particular code but the cold CPU and caches. - **Things we tried but did not ship (2026-10-19, all measured alternately):** - Server thread QoS set to USER_INTERACTIVE: 37.5\~39.9µs vs 39.1\~39.2µs, no difference. - LTO + `codegen-units=1` (packing the code together so less of it is touched when cold): 1\~2µs (3%), noise level. - Reading only as many bytes as kqueue reported, to skip the "nothing more" check read: no difference. - The server spinning for 200µs after replying, waiting for the next request: `send-keys` in back-to-back runs goes from 8.0µs to 4.5µs (5.8x vs tmux -C), but with pauses it is unchanged. In exchange, every request holds a core for 200µs, which eats into the CPU goal. Switching to shared memory would get down to 1.5\~2µs on paper, but again only for back-to-back runs, and a few µs are not perceptible at the speed of a human or of claude. - `mach_eventlink`: a thread waits on only one link, so putting it into a server that watches every connection with a single kqueue would need a thread per connection (an allocator heap per thread; +0.8MB in the earlier GCD experiment). Mach messages, which can be used together with kqueue, are only 2µs faster than sockets. - **We did not ship a screen mirror on the client side (like mosh, or tmux `%output`) (2026-10-19).** If the client receives output through `attach -r` and interprets the screen itself, capture needs no round trip. But mirroring all 200 busy sessions took server CPU from 2.50% to 6.15%, because sessions someone is watching are read and sent immediately instead of being collected over 300ms. The client also has to re-interpret 573KB per second. And unlike a server capture, a mirror's capture does not check for pending output, so it may be as stale as it is fast. Keeping the batched reads leaves CPU unchanged but makes the screen up to 300ms late. `attach -r` already covers continuously receiving the screen, and `wait-pane` covers waiting for changes. - **The synchronous client (`zeroptySync`, `Client` in `zeropty.node`) is 3.0\~4.1x per run (⛔ †, p50 0.13\~0.26ms, the same three runs).** It writes and reads with blocking I/O without going through the event loop, so its p50 is about 25\~48% lower than the async one's. The remaining lower bound is the round trip of two processes, the server and Node, waking each other in turn. When the server is busy, p95 spikes up to 1ms. - **Embedded has no such lower bound.** The remaining lower bound is the time for the Node thread to sleep and wake (\~40\~50µs). Called back to back, it takes 2.6µs per call. ## Latency: from the shell (`bench/shell.zsh`) From a shell script, 200 commands each, in alternating rounds: `bench/shell.zsh` run three times, two rounds each, six rounds in all (2026-10-21, Mac mini M1). Units are ms (p50 / p95), and ranges are the values of the six rounds. | Command | tmux | zeropty program | zeropty zsh function | zsh function vs tmux | |---|---|---|---|---| | `has-session` | 6.04\~6.19 / 6.74\~7.20 | 3.59\~3.95 / 4.22\~4.85 | 0.11\~0.17 / 0.14\~0.38 | **35.5\~56.2×** | | `send-keys -l` | 6.11\~6.33 / 6.92\~9.86 | 3.68\~3.92 / 4.25\~5.39 | 0.12 / 0.15\~0.20 | **50.9\~52.7×** | | `capture-pane -p -S -2000` | 6.67\~6.75 / 7.41\~7.76 | 3.86\~4.23 / 4.47\~5.52 | 0.50\~0.55 / 0.57\~1.13 | **12.1\~13.5×** | - **Both programs took longer than on 2026-10-18** (tmux 4.35\~5.02ms → 6.04\~6.75ms, the zeropty program 2.47\~2.60ms → 3.59\~4.23ms), on a busier machine. The zsh function changed little (0.11\~0.48ms → 0.11\~0.55ms), so its multipliers went up. - **Program vs program is 1.5\~1.7x.** The cost of starting a process for each command (1.3ms even for an empty C program) is the lower bound. - **The zsh function (`shell/zeropty.zsh`) avoids that lower bound.** It uses the socket module built into zsh (`zsh/net/socket`) to talk to the server directly from inside the shell, without starting a process. - **bash does the same with a builtin (`shell/zeropty_bash.c` → `dist/zeropty_bash.dylib`).** bash 3.2 has no µs clock, so the values are averages: the total time of a 200-iteration loop minus the time of an empty loop (at the end of the same script). | Command (bash, average, six rounds) | tmux | zeropty program | zeropty bash builtin | builtin vs tmux | |---|---|---|---|---| | `has-session` | 5.968\~6.470ms | 3.630\~3.860ms | 0.041\~0.075ms | **79.5\~157×** | | `send-keys -l` | 6.022\~6.313ms | – | 0.043\~0.048ms | **125\~146×** | | `capture-pane -p -S -2000` | 6.733\~7.066ms | – | 0.114\~0.138ms | **48.7\~61.9×** | Six rounds, as above. One round of the builtin's `has-session` came out at 2.448ms, against 0.041\~0.075ms in the other five; the other commands in that round were normal, so we take it for a stall of the machine (not checked) and leave it out of the range. Counting it, the low end would be 2.4×. ## Bulk output A program writes 5.4MB (60,000 lines, with colors) as fast as it can (`bench/bulk.sh`, 2026-10-21, Mac mini M1, alternately with tmux: 3 runs each, twice). | Item | tmux | zeropty | Multiplier | |---|---|---|---| | Until the program finishes writing | 166\~176ms | 3.0\~4.6ms | **36.0\~58.6×** | | From when the program starts writing until the last line is visible in `capture-pane` | 169\~188ms | 15\~16ms | **10.5\~12.5×** | - **The screen time passes 10x only because tmux was slower.** On 2026-10-18 it was 159\~164ms against zeropty's 14\~16ms (9.9\~11.7×, borderline). zeropty took the same time on both days. - **When output fills the pipe, zeropty first reads everything until the program stops, and parses afterwards.** So the program never waits on parsing. Previously it read 16KB at a time and the program was blocked while parsing, which took 17ms. The lower bound is 1.6ms, the time the same program takes writing to `cat > /dev/null`. - **On the screen side, compression of rows scrolling out is deferred until the end of a single write.** Of the 60,000 lines, only the rows that remain in scrollback (2000 lines) are compressed. Parsing 5.4MB went from 16.5ms to about 9ms (`cargo run --release --example parse_bench bulk`). - Screen time is measured by calling `capture-pane` without pause. Each call starts a process (zeropty \~2.5ms, tmux \~4.5ms), so there is that much error. The previous measurement (31\~41ms) included the cost of `new-session` and of running perl to take timestamps. ## Waiting: `wait-pane` instead of polling (`node bench/wait.mjs 200`) We compared two ways for a host to find out "whether a session has finished its work". 200 sessions replay a real claude workload (`tests/streams/claude2.trace`) for 5\~25 seconds and then print `TASK-DONE`. - **Polling:** the host reads the remaining sessions with `capture-pane` every P ms. This is the only option for a tmux host. - **`wait-pane`:** send `wait-pane -m TASK-DONE` once per session and wait for the reply. Measured over two alternating rounds on a Mac mini M1, 2026-10-21. "Detection latency" is from the moment the line is printed until the host notices. | Method | Server CPU | Host (Node) CPU | Detection latency p50 / p95 | |---|---|---|---| | 500ms polling | 2.0% | 0.6% | 253\~262 / 479\~489ms | | 200ms polling | 2.9\~3.0% | 1.1\~1.3% | 86\~96 / 173\~196ms | | **`wait-pane`** | 3.8\~3.9% | **0.3\~0.4%** | **1 / 2ms** | - **Time to notice is 86\~262x shorter (p50 86\~262ms → 1ms).** Automation that repeats "send the next input when the prompt appears" gets that much faster at every step. The polling code in the host also goes away. - **The CPU is not free.** Server CPU rises by 0.8\~1.0%p over 200ms polling (200 sessions). That is because waiting sessions are read immediately rather than having output collected over 300ms. Just reading the same sessions without batching raised server CPU from 2.0% to 2.8%. Once the wait ends, it goes back to normal. - **Text matching only checks the rows changed by the current read.** The first version converted the whole screen to text and checked it on every read, and server CPU was 4.4\~6.3%. Checking only changed rows brought it to 2.7\~3.2%, and the remaining cost is that of immediate reads. On a screen switch (leaving the alternate screen), old rows become visible again, so the whole screen is checked. - **We tried waiting while keeping the batched reads.** Server CPU was 15% lower than polling, but detection latency was p50 340ms, slower than 500ms polling. So we chose immediate reads. ## Finding text across sessions: `grep-panes` (`bench/grep.zsh 1000`) Out of 1000 idle claude screens, 3 were marked, and this is the time it took to find those 3 (2026-10-21, Mac mini M1, `bench/grep.zsh` twice, two rounds each). | Method | Time | Server memory (1.41MB before) | |---|---|---| | `capture-pane` per session via the CLI | 4.2\~4.3 s | 1.70\~2.05MB | | `capture-pane` per session via the zsh function | 0.88\~1.02 s | 1.71\~1.81MB | | `capture-pane` per session via Node (`client/zeropty.mjs`) | 92\~118ms | 2.11\~2.48MB | | **`grep-panes`** | **16\~32ms** | **1.60\~1.76MB** | - **"Which session is waiting for input?" from the terminal goes from about 4 seconds to 16\~32ms.** - The memory after each method includes screens left expanded by the rounds before it; `grep-panes` runs first in each round, and its first-round value is 1.60\~1.63MB. - **Screens are not thawed.** `capture-pane` keeps the screens it reads expanded until they freeze again (a little over +1MB for 1000), while `grep-panes` reads a copy of the frozen screen. - tmux has no command for searching screen contents (`find-window -C` is an interactive chooser). ## Status of the 10x goal (all items) | Item | Status | |---|---| | CPU: every load at 200 and 400 (screen redraw, whole claude transcript dump, real claude workloads) | ✅ 13.7\~17.9× | | Memory: screen redraw, real claude workloads at 200 and 400 (standalone server) | ✅ 10.4\~147× | | Memory: embedded 400 idle, 200 and 400 real workloads | ✅ 13.1\~19.9× (polled), 10.4\~14.4× (engine thread, 400 idle 10.8×) | | Memory: increment per session | ✅ idle, screen redraw and real workloads 14.1\~1207×. ⛔ whole claude transcript dump 7.9\~28.9× (a 352KB difference between two runs, see the standalone server section) | | Latency: vs running tmux per command | ✅ socket 31.0\~72.1×, embedded 54.7\~280× (per run 57\~268× †) | | Latency: vs tmux control mode, embedded | ⛔ keystroke→screen 8.9\~10.8× (per run 9\~10× †), send-keys 6.0\~8.1× (echo in cooked sessions), capture 3.2\~10.0× (causes in the latency section above). The low end of the range is under 10x, so these are not in the README | | Latency: vs tmux control mode, socket | ⛔ 1.6\~2.8× (async, per run 1.7\~2.8× †), 3.0\~4.1× (synchronous client, †), lower bound of an inter-process round trip | | Absolute memory: standalone server idle, whole claude transcript dump | ⛔ 4.1\~6.9×, server process lower bound (empty C process 0.87MB, memory section below) (surpassed with embedded polled: idle 12.8\~17.7×, real workloads 13.1\~19.9×. Even with the server plugin on and sessions handled through the CLI and attach, 200 idle is 12.8\~13.1×) | | Memory: embedded 200 idle | ✅ 12.8\~13.0× (polled, the Node default). Engine-thread mode is ⛔ 7.2\~7.5×, thread cost 308\~309KB | | Bulk output: until the program finishes writing | ✅ 36.0\~58.6× | | Bulk output: until visible on screen | ✅ 10.5\~12.5× (borderline 9.9\~11.7× on 2026-10-18; tmux was slower this time and zeropty the same. Screen time includes the error of starting `capture-pane` as a process; see the bulk output section above) | | Latency: from the shell, zsh function vs tmux CLI | ✅ 12.1\~56.2× | | Latency: from the shell, bash builtin vs tmux CLI | ✅ 48.7\~157× (program vs program 1.5\~1.7×, lower bound of process startup cost) | ## Reproducing the measurements ```sh ./build.sh bash bench/suite.sh # standalone server memory and CPU, about 15 minutes clang -O2 -o /tmp/embed_host bench/embed_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist /tmp/embed_host 400 trace $PWD # embedded memory (idle | trace) /tmp/embed_host 200 idle $PWD polled ~/.zeropty/eh.sock # embedded memory with the server plugin on clang -O2 -o /tmp/listen_host bench/listen_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist /tmp/listen_host ~/.zeropty/lh.sock $PWD & # server plugin latency: latency_c ~/.zeropty/lh.sock 400 [interval µs] node bench/latency.mjs 200 # latency bench/bulk.sh zeropty; bench/bulk.sh tmux # bulk output bench/shell.zsh # latency from the shell (zsh function, program, tmux) node bench/wait.mjs 200 # polling vs wait-pane (CPU, detection latency) bench/grep.zsh 1000 # finding text across 1000 sessions (grep-panes vs repeated capture) ``` Before measuring 1000 sessions, check the per-user process limit (`kern.maxprocperuid`, 2666 by default). `load.sh` checks it beforehand and stops if it would be exceeded. --- # Changelog Every user-visible change, release by release. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/); what counts as a breaking change is in [Versioning](/guide/versioning). Release files are on [GitHub Releases](https://github.com/CreatiCoding/zeropty/releases). ## [Unreleased] ## [0.1.6] - 2026-10-24 ### Added - Linux support (x86_64, glibc 2.29 or later, kernel 5.3 or later). The engine waits on epoll instead of kqueue, behind the same `zeropty::engine` types, and the shim loads with `LD_PRELOAD` (`shim.so`), so `zeropty-tty` runs any dynamically linked program as it is, with no copy and no signing. Each release now also carries `zeropty--linux-x86_64.tar.gz` (`zeropty`, `shim.so`, `zeropty-tty`, `claude-zeropty`), in the same `SHA256SUMS`. A statically linked program (most Go binaries) never loads the shim and sees no terminal. ## [0.1.5] - 2026-10-21 ### Fixed - `C-c`, `C-\` and `C-z` typed at a job in a shell with job control (zsh, `bash -i`) run through `zeropty-tty` went to the shell, not the job: `sleep 30` could not be stopped. A job leads its own process group, and the shell's `tcsetpgrp` was swallowed by the shim. The shim now reports it, and zeropty signals that group as a tty does its foreground group, so `C-z` and `fg` work too, pipelines included. Only a process group with a member under the session's program is taken. A shell started before the upgrade keeps the old shim: start it again. ## [0.1.4] - 2026-10-21 ### Fixed - `zeropty-tty`: a passphrase typed at `ssh`, `ssh-keygen`, `ssh-add` or any program using `readpassphrase` or `getpass` was echoed onto the session's screen, and so stayed in `capture-pane` output and the scrollback. libc turns echo off there with its own internal calls, which the shim did not reach; the shim now handles these two functions itself on the session's terminal. This covers a program the shim is in, such as `zeropty-tty ssh-keygen`; `/usr/bin` programs typed at a shell in the session still run without the shim. Outside a session nothing changes. ## [0.1.3] - 2026-10-21 ### Added - Each release now carries a prebuilt macOS archive, `zeropty--darwin-universal.tar.gz` (Apple silicon and Intel, macOS 11 or later), and `SHA256SUMS`, so zeropty installs without Rust: download, `shasum -a 256 -c SHA256SUMS`, unpack, and run from the folder. It holds `zeropty`, `shim.dylib`, `zeropty-tty`, `claude-zeropty` and the licenses, signed ad hoc. The embedding libraries and the bash builtin still come from `./build.sh` ([README](/guide/install)). ## [0.1.2] - 2026-10-20 ### Fixed - `zeropty-tty`: `/dev/tty` opened for reading and writing (`fzf` with its stdin piped in) could read keys but every write failed with `EBADF`, so fzf drew no list and took no pick. A session's terminal is now one socket, as a PTY is one file: stdin, stdout and stderr are that socket (stdin was a pipe), and `/dev/tty` in any mode is it. `C-d` still ends input for good, and output keeps flowing after it. As on a PTY, `O_NONBLOCK` set on one of stdin, stdout and stderr is now set on all three. Sessions started by an older server and kept by `restart-server` keep their input pipe; sessions started by this version should not be handed back to 0.1.1 or earlier (`C-d` would not reach them). ## [0.1.1] - 2026-10-20 ### Fixed - `zeropty-tty`: the shim treated any program's fds 0-2 as a terminal, so a program whose stdout was piped or redirected got `\e]zeropty-tty;N\a` mode signals mixed into that output, a program reading `/dev/null` as stdin failed (kqueue `EINVAL`), and children inherited the effect. Now only the session's own pipe and socket count, named by the server in the new `ZEROPTY_TTY_ID` variable, and mode signals go only to the session's output. - `zeropty-tty`: the signed copy took the name of the link's target (`claude` ran as `claude.exe`), so `ps` and `pgrep claude` did not find it. A program, copied or not, now runs under the name it was run by. - `display-message` with `-t` naming a session that does not exist now prints the format with the session's variables empty and exits 0, as tmux does, instead of failing with exit 1. To test whether a session exists, use `has-session`. Every other command still fails with exit 1, as in tmux. - A missing `-t` target is now reported in tmux's words. Without a `:`, a bare name gives `can't find pane: x` (`capture-pane`, `send-keys`, `pipe-pane`, `paste-buffer`, `resize-pane`) or `can't find window: x` (`resize-window`), an id gives the session, window or pane error for `$`, `@` or `%`, and `x.0` gives `can't find window: x`; `set-environment` gives `no such session: ` and the target as given. Exit codes are unchanged (1). ## [0.1.0] - 2026-10-20 The first public version: a session engine for programs that drive many terminal programs (coding agents, scripts, test harnesses), which accepts tmux's command format without a PTY. macOS only. Tagged `v0.1.0` on the first public commit, `c612089`. Known limitations: macOS only (kqueue, the DYLD shim). One program per session, so there are no windows, panes, status line, copy mode, key bindings or configuration file. `wait-pane` is not available as an embedded function call, only over the socket (including an embedded `listen`). Programs protected by SIP or signed with the hardened runtime cannot load the shim, so `zeropty-tty` runs a re-signed copy instead, and setuid or entitlement programs (`top`, `ps`) do not work. End of file with `C-d` closes the session's input (a pipe has no one-time EOF). Before 1.0, a MINOR release may break the interfaces in docs/VERSIONING.md. ### Added - **Server and CLI** (`zeropty`), with the same arguments as tmux: `new-session`, `has-session`, `kill-session`, `list-sessions`, `kill-server`, `send-keys` (key names compared byte for byte with tmux 3), `capture-pane` (`-p -e -J -N -S -E -b -q`), `load-buffer`, `paste-buffer`, `display-message`, `pipe-pane`, `rename-session`, `resize-window`/`resize-pane`, `set-environment -g`/`show-environment -g`, `attach-session` (`-r` read-only), `-V`, and 25 tmux format variables. Unknown flags are errors. The first `new-session` starts the server. - Commands tmux does not have: `wait-pane` (waits for text or for quiet without polling), `grep-panes` (searches the screens of all sessions), `pull-sessions`/`push-sessions` (moves running sessions to another process without touching the programs), `restart-server` (upgrades the server without ending sessions). - **No PTY**: a session's program talks to pipes, and the engine emulates the line discipline (Enter, the `C-c` signal, line editing, echo, `C-d`). For programs that need a terminal (claude, vim, less, python), `shim.dylib`, `claude-zeropty` and `zeropty-tty` answer `isatty`, `tcgetattr`, the window size and `/dev/tty`. - **Screen emulator**: 6 bytes per cell, compared with the `vt100` crate (0.16) on recorded claude sessions and random input. Quiet screens are folded away and later compressed with zstd. - **Socket protocol**: one JSON line per request and per reply on `~/.zeropty/mux.sock` (`ZEROPTY_SOCK`), an optional raw reply format, and attach frames. - **Embedding**: the C ABI (`libzeropty_c.dylib`, `zeropty.h`: `zeropty_open`, `zeropty_command`, `zeropty_listen`, and `zeropty_open_polled`/`zeropty_fd`/`zeropty_timeout_ms`/`zeropty_process` for polled use) and the Node module (`zeropty.node`, `client/embedded.mjs`) run the engine inside your process. `listen` serves the same socket protocol from the host process. - **Clients**: `client/zeropty.mjs` (Node, keeps one connection open), and a zsh function (`shell/zeropty.zsh`) and a bash builtin (`shell/zeropty_bash.c`) that talk to the socket without starting a process. - Environment variables `ZEROPTY_SOCK`, `ZEROPTY_HISTORY`, `ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`, `ZEROPTY_COLD`, `ZEROPTY_COLD_DIR`, `ZEROPTY_RECORD` (docs/USAGE.md). - Benchmarks measured interleaved with tmux 3.7c on one Mac mini M1 (docs/BENCHMARKS.md): 11 of the 14 measured items are at 10x or better, and 3 are not there yet (memory with 400 idle sessions on the standalone server at 6.2x, and `send-keys` against tmux control mode at 8~10x embedded and 2.2~2.8x over the socket). The reasons are in that document. - A plugin system ([Plugin API](/reference/plugin-api)): plugins receive session create, exit, rename and output events and add new commands. The Rust `Plugin` trait (`API_VERSION` 1), `plugin-attach` over the socket (any language, out of process), C callbacks with `zeropty_add_plugin`, dylib plugins with `zeropty plugin-run`/`zeropty_load_plugin`, an opt-in autostart directory `ZEROPTY_PLUGIN_DIR`, and a status check with `list-plugins`. With no plugins there is no cost, and a plugin that panics, hangs or is slow cannot hold up the engine. - Command calls that do not wait: C `zeropty_command_start`, `zeropty_call_poll`, `zeropty_call_due_ms`, `zeropty_call_free`, and Node `Engine.commandStart`. - `zeropty --version`: the same output as `zeropty -V` (`zeropty 0.1.0`). The zsh function, the bash builtin and `client/zeropty.mjs` also answer it by running the program, as they do for `-V`. - The format variable `#{version}`: the version of the running server (or embedded engine). A `display-message -p` that uses only this variable works without a session (`zeropty display -p '#{version}'`). - C ABI: `ZEROPTY_VERSION_MAJOR`, `_MINOR`, `_PATCH`, `_PRERELEASE`, `_IS_PRERELEASE`, `_STRING` and `_NUMBER` in `zeropty.h`, and `zeropty_version()`, which returns the version of the linked library. `ZEROPTY_VERSION_NUMBER` does not include the prerelease, so `0.2.0-rc.1` and `0.2.0` have the same value; tell them apart with `ZEROPTY_VERSION_IS_PRERELEASE`. - Node: `version()` in `zeropty.node` and `client/embedded.mjs`, and `VERSION` in `client/zeropty.mjs` and `client/embedded.mjs`. - The versioning policy `docs/VERSIONING.md`: the compatibility surface whose changes count as breaking (the CLI's arguments, output and exit codes, the socket protocol, the session record, the C ABI, the Node API, environment variables, `dist/` names) and the MSRV (Rust 1.88). Before 1.0, a MINOR release may break this surface, and such changes are announced as `**BREAKING**` entries. - `zeropty --help` (`-h`, `help`) prints the list of commands and options, and `zeropty help ` prints that command's usage and a link to its documentation. It answers without a server. - Documentation: a reworked Korean README, a documentation index (`docs/README.md`), a FAQ, runnable examples (`examples/`), and rustdoc with examples for every public Rust item. ### Fixed - The CLI no longer panics when the reader closes the pipe early (`zeropty capture-pane -p ... | head -1`). [Unreleased]: https://github.com/CreatiCoding/zeropty/compare/v0.1.6...HEAD [0.1.6]: https://github.com/CreatiCoding/zeropty/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/CreatiCoding/zeropty/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/CreatiCoding/zeropty/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/CreatiCoding/zeropty/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/CreatiCoding/zeropty/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/CreatiCoding/zeropty/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/CreatiCoding/zeropty/releases/tag/v0.1.0 --- # Contributing Thank you for your interest in zeropty. Bug reports, documentation fixes, shared measurements and code contributions are all welcome. ## Before you start - **This is a public repository.** Never commit secrets, tokens, personal paths (`/Users//...`) or names, internal host names, or recordings of real sessions you have not checked. A recorded screen shows the recorder's prompts and folders. Always look at `git diff --cached` before you commit. - **The goal is 10x better than tmux in every measured category.** "Better than tmux" is not done. We never change the goal to something easier. - **No code for one particular user or program.** Programs use zeropty the way they use tmux. claude is optional, and tests and benchmarks must run with plain programs such as `cat` and `printf`. - The authoritative code rules, measurement methods and the 10x goal are in [AGENTS.md](https://github.com/CreatiCoding/zeropty/blob/main/AGENTS.md) (the same rules apply to agents and people). - For a large change, it helps to agree on the direction in an [issue](https://github.com/CreatiCoding/zeropty/issues) first. - Do not report security problems as issues; follow the [security policy](https://github.com/CreatiCoding/zeropty/blob/main/SECURITY.md) instead. - Everyone who takes part follows the [code of conduct](https://github.com/CreatiCoding/zeropty/blob/main/CODE_OF_CONDUCT.md). ## Building and testing You need macOS, Rust (stable, edition 2024; the minimum supported Rust version (MSRV) is 1.88) and the Xcode Command Line Tools. Node 18 or later is useful for running the examples. ```sh git clone https://github.com/CreatiCoding/zeropty && cd zeropty git config core.hooksPath .githooks # checks the message on commit and runs scripts/check.sh before push (needs node) ./build.sh # zeropty, the shim, the C and Node bindings, and the bash builtin into dist/ cargo test --release --workspace # vt100 comparison, server, embedded, session handover, plugins, doc examples RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace # API docs: must build without warnings ``` - `./build.sh` deletes the binaries in `dist/` before copying. On macOS, overwriting a running binary in place kills that process. - If you changed the screen emulator, run more random input: `SEEDS=20000 cargo test --release random_streams` - We recommend working in a `git worktree` so you do not disturb a checkout that other sessions are using. - Tests run in parallel and start session processes at the same time. Do not stop at a single passing run: if you changed code that depends on timing (sockets, process exit, waits), run the whole suite several times. For example, even after a listener is closed, a child process that another thread has just started holds a copy of the fd until it calls exec, so a socket at the same path can briefly accept connections as if it were a "live server". For the same reason, a test that closes one end of an fd and then immediately checks for the end (EOF, EPIPE) on the other does both the close and the check inside a `sys::fd::no_spawns()` guard. No process is started while the guard is held, so no copy of the closed fd is left behind (see `keys_and_buffers_reach_the_program` in `engine::commands`; the measurements are in the comment on `no_spawns`). ## Finding your way around the code | File | What it does | |---|---| | `src/term/` | The screen emulator (same results as the `vt100` crate 0.16) | | `src/sys/` | The OS boundary (kqueue, fds, processes, time, `tty`) | | `src/engine/` | The engine: sessions and every command. A new command is one row in `COMMANDS` in `commands.rs` plus one handler | | `src/protocol.rs`, `src/server.rs` | The socket protocol (bytes) and the socket server | | `src/host.rs`, `src/embed.rs` | One turn of the host loop; the engine inside a host (on its own thread or in the host's loop) | | `src/engine/handover.rs` | Moving sessions between processes | | `src/plugin.rs`, `src/plugin/` | Plugins ([Plugin API](/reference/plugin-api)). The stable API for plugin authors is `api.rs`; for hosts, `Plugins` and `Call` | | `src/main.rs`, `src/cli/` | The `zeropty` program (standalone server, CLI, attach, `plugin-run`, `--help`, `--version`) | | `shim/shim.c` | The shim that intercepts `isatty` and friends | | `bindings/c`, `bindings/node`, `client/` | The C ABI, the Node module, the Node client | | `tests/` | `tests/diff.rs` (vt100 comparison), `tests/server.rs`, `tests/embed.rs`, `tests/handover.rs`, `tests/version.rs` (the version number agrees everywhere) | | `api/`, `scripts/` | The public API baseline (`api/*.txt`), release and check scripts ([Releasing](/guide/releasing)) | | `bench/` | Benchmarks | The architecture diagram is in [Design](/guide/design#overall-architecture). ## Making a change **Correctness** - The screen must match the `vt100` crate (0.16). `cargo test` feeds recorded claude sessions and random input to both and compares them, with random freeze, thaw and packing mixed in between reads. - If you added a new code path, break it on purpose once (a mutation) and check that a test fails. If none fails, add a test. Restore the code afterwards. - Unless the change is meant to alter it, `capture-pane` output must stay byte-identical for the same screen (compare against a saved result). - End to end: if you can, start a real claude in a session through `dist/claude-zeropty` and check `send-keys`, `paste-buffer`, `capture-pane -e -S`, ONLCR (`echo` lines start at column 0) and `kill-session`. **Compatibility** - Keep the CLI behavior, socket protocol, session record, C ABI (`bindings/c/include/zeropty.h`), Node API, environment variables and `dist/` names backward compatible (what counts as the compatibility surface is in [Versioning](/guide/versioning)). Adding is fine, but changing or removing is a breaking change: discuss it in an issue first and mark the commit as breaking. - If you changed the C ABI or the Node exports, update `api/*.txt` in the same commit with `./build.sh && node scripts/check-api.mjs --update`. - Never edit version numbers by hand. At release time, `node scripts/release.mjs bump ...` updates every place ([Releasing](/guide/releasing)). - The interface is tmux's command format. It must behave the same through the CLI, the socket and the embedded library. **Code style** - Write comments in English and record **why** something is done, what the gotcha is, and the measurement that justified the choice. For example: `// v2: 400 busy claude sessions took 3.5 MB against 4.5 MB with v3` - All code is rustfmt-formatted (configured in `rustfmt.toml`). Run `cargo fmt --all` before committing; `scripts/check.sh` verifies it with `cargo fmt --all --check` ([Releasing](/guide/releasing#formatting)). - `cargo clippy --workspace --all-targets -- -D warnings` must pass. Lints allowed for the whole crate, with their reasons, are in `Cargo.toml`; a new exception gets an `#[allow(...)]` with a reason right where it applies. - On hot paths (the engine's tick and output handling, the screen emulator, capture), do not add dynamic dispatch or allocations. Use static dispatch through generics and traits where needed. - Give public items doc comments (`///`). The crate has `#![warn(missing_docs)]` enabled. - Write commit messages in English, in the [Conventional Commits](/guide/contributing#commit-messages-conventional-commits) format. ## Commit messages: Conventional Commits We follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/). Messages are written in English. ``` ()!: BREAKING CHANGE: Refs: #123 ``` - The first line is at most 100 characters, with no period at the end. Separate it from the body with a blank line. - A `!` or a `BREAKING CHANGE:` footer marks a **breaking change**. What counts as breaking is in [Versioning](/guide/versioning) (CLI, socket protocol, session record, C ABI, Node API, environment variables, build outputs). - One thing per commit. Keep refactoring and behavior changes in separate commits. | type | When | CHANGELOG | Release level (before / after 1.0) | |---|---|---|---| | `feat` | A new command, flag, function or environment variable | Added | PATCH / MINOR | | `fix` | A bug fix (`fix(security)` for a security fix) | Fixed (Security) | PATCH / PATCH | | `perf` | Faster or smaller, same behavior | Changed | PATCH / PATCH | | `revert` | Reverting an earlier commit | Changed | PATCH / PATCH | | `refactor` | A code change with the same behavior | – | – | | `docs` | Documentation only | – | – | | `test` | Tests only | – | – | | `build` | `build.sh`, Cargo settings, dependencies | – | – | | `ci` | Check scripts and hook settings (`scripts/check.sh`, `.githooks/`) | – | – | | `chore` | Anything else users do not see (`chore(release): X.Y.Z`) | – | – | | `style` | Formatting only | – | – | | any type + `!` | A breaking change | Changed, `**BREAKING**` | MINOR / MAJOR | The scope is optional and lowercase. Common ones: `cli`, `server`, `engine`, `term`, `embed`, `plugin`, `protocol`, `handover`, `c-abi`, `node`, `shim`, `shell`, `bench`, `release`, `ci`. Examples (hypothetical changes): ``` feat(cli): add --version as an alias of -V fix(term): keep the cursor column after a wide character at the margin perf(engine): read coalesced output in one kevent batch 400 replaying sessions: % -> % CPU (bench/suite.sh, interleaved with tmux, twice). refactor(c-abi)!: take options as a struct in zeropty_open BREAKING CHANGE: zeropty_open(const char *) is now zeropty_open(const zeropty_options *); pass NULL for defaults. ``` ## Changelog: CHANGELOG.md - The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). A user-visible change (`feat`, `fix`, `perf`, `revert`, a breaking change) adds one line **in the same PR** under the matching heading (Added, Changed, Deprecated, Removed, Fixed, Security) below `## [Unreleased]`. The commit type decides the heading: `feat` → Added, `fix` → Fixed (`fix(security)` → Security), `perf` and `revert` → Changed, a breaking change → a `**BREAKING**` line under Changed or Removed. `scripts/check.sh` checks the headings of new entries with the same rules as the git-cliff draft ([Releasing](/guide/releasing#why-it-is-semi-automatic)). - Do not paste the commit log; write what users need to know, **in English**, and add the same entry in Korean to the Korean copy, [Changelog](/ko/guide/changelog). `node scripts/release.mjs notes` produces a draft for you (from the English commit messages). - If you changed the C ABI or the Node exports, update `api/*.txt` with `./build.sh && node scripts/check-api.mjs --update` and include it in the same commit. A line that disappears is a breaking change. - A breaking change starts with `- **BREAKING** ...` and says what users have to change. - For performance numbers, use only values measured in [Benchmarks](/guide/benchmarks). - PRs that users do not see (`refactor`, `docs`, `test`, `ci` ...) need no entry. When a single commit has nothing to record, such as a fix to a feature that has not been released yet, end its message with a `Changelog: skip` footer (not allowed for breaking changes); when the whole PR is like that, say so in the PR description. The maintainer then checks it with `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh`. - Do not touch version numbers or `## [X.Y.Z]` headings. `scripts/release.mjs` handles them at release time ([Releasing](/guide/releasing)). ## Changes that affect performance A change that moves memory, CPU or response-time numbers updates [Benchmarks](/guide/benchmarks) in the same commit (date, commit, machine, ratios). - Measure tmux and zeropty **interleaved under the same load** (`bench/latency.mjs` does this). Measuring one after the other drifted by more than 30% on this machine. - The server often runs on efficiency cores, so a single measurement varies by ±10\~15%. Repeat before drawing a conclusion, and for memory, prefer the per-session increase between 200 and 400 sessions. - Check the per-user process limit (`sysctl kern.maxprocperuid`) first. Stop leftover benchmark servers with `kill-server` on their socket and check with `ps -U $(id -u) | wc -l`. - Profiling: `sample 20 -file out.txt`. Compare `heap_bytes` from `zeropty stats` (bytes requested from the allocator) with the footprint, and break one screen down with `zeropty mem -t `. - Confirm a hypothesis with a measurement before acting on it. Several "obvious" causes turned out to be wrong; a profile or a counter showed the real one. - In BENCHMARKS.md, mark items that fall short of 10x (⛔) and items not measured yet. Never round a ratio up or drop a bad row. - README shows only items measured at 10x or better against tmux, with the measured ranges as they are. An item that falls below 10x is removed from README. How to reproduce: [Benchmarks: reproducing the measurements](/guide/benchmarks#reproducing-the-measurements) ## Documentation - Documentation prose is written in English in README.md and `docs/`. The Korean translations live in [소개](/ko/guide/introduction) and [docs/ko/](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko) and are kept in step with the English files (same sections) whenever a document changes. Code, commands and identifiers stay in English in both. - After changing documents, run `node scripts/check-links.mjs` to check that every relative link and `#anchor` resolves (in both the English and the Korean files). - Run every command and piece of code before putting it in a document. - The command reference is in [Commands](/reference/commands), the design and the numbers behind it in [Design: how we got to 10x](/guide/design), and every measurement in [Benchmarks](/guide/benchmarks). - README's images (logo, benchmarks, architecture) are loaded from absolute `https://cdn.creco.dev/zeropty/` URLs so that they also show in the GitHub mobile app. The sources are in `docs/assets/`; regenerate them with `node docs/assets/make.mjs` followed by `sh docs/assets/webp.sh`. Overwriting a file under the same name would change the CDN file and README at different times, and the front page would show an old image (with old numbers) in between. So a redrawn image gets a new name by bumping `rev` in `webp.sh` (for example `bench-v3-light.webp` → `bench-v4-light.webp`), and README's `` URLs change in the same commit. The maintainer uploads the new files to the CDN and then merges, so the numbers in the front-page images never disagree with the tables. `sh docs/assets/cdn-check.sh` checks that every CDN image in README opens with 200 and names the files not uploaded yet. The last step of `scripts/check.sh` runs the same check (skipped with `--quick`). The maintainer merges a PR that changes images after uploading them to the CDN and seeing this check pass. - The terminal demo is recorded by actually running the one-minute quick start with `node docs/assets/demo.mjs` after `./build.sh` (needs Google Chrome and `img2webp`). The resulting `demo-{light,dark}.webp` files are not committed to the repository; the maintainer uploads them to the CDN, and they go into README as a `` with absolute URLs, like the other images. ## Pull requests 1. Work on a branch (do not push straight to `main`; no force-push). 2. Run `scripts/check.sh` and make sure everything passes. The repository has no hosted CI: this script is every check that runs before something lands on main (the one hosted job, `.github/workflows/release.yml`, builds the release archive and also runs on pull requests that change what goes into it), and the maintainer runs the same script before merging. With `git config core.hooksPath .githooks`, `.githooks/pre-push` runs this script on every push (`--quick` with `ZEROPTY_PRE_PUSH=quick`). ```sh scripts/check.sh # every check (takes a few minutes; the first MSRV check compiles from scratch) scripts/check.sh --quick # without the examples, MSRV, rustdoc, CDN images and the git-cliff draft ``` The steps run in order from the first; on a failure, it names the step and stops. - **build:** `./build.sh` (release build of the workspace, `dist/`). - **tests:** `ZEROPTY_REQUIRE_ALL=1 cargo test --release --workspace --locked` (including doctests). Tests that would be skipped because a tool (cc, node, python3) is missing fail instead, so a pass means every test ran. - **versions agree:** `node scripts/release.mjs check`. If HEAD has a tag, it also checks the tag against the version with `--tag `. - **public API:** `node scripts/check-api.mjs`, plus `--base ` when there is a base branch (whether lines that disappeared are marked as breaking). - **release tooling tests:** `node --test scripts/test/*.test.mjs`. - **version smoke tests:** `scripts/smoke-version.sh` (CLI, server, C and Node versions). - **clippy:** `cargo clippy --workspace --all-targets --locked -- -D warnings`. - **rustfmt:** `cargo fmt --all --check`. - **commit messages and changelog:** whether every commit after the base branch is a Conventional Commit and, if there are user-visible commits, whether `[Unreleased]` has new entries under the headings that match their types (`node scripts/check-commits.mjs --changelog`). - From here on, steps are skipped with `--quick`. **rustdoc:** `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace` (undocumented public items, broken doc links). **examples:** the six shell, Node, C and Rust examples. **MSRV:** `rustup run 1.88.0 cargo check --workspace --all-targets --locked` (needs `rustup toolchain install 1.88.0`). **git-cliff draft:** whether `node scripts/release.mjs notes` produces a draft. **README images:** `docs/assets/cdn-check.sh`. The base branch is `$ZEROPTY_CHECK_BASE`; if unset, the current branch's upstream; if there is none, `origin/main`. 3. Each example uses a server on its own temporary socket or an engine inside the process and cleans up when done, so it never touches sessions in use. 4. PRs are squash-merged by default, so **the PR title becomes the commit message on main**: write it as a Conventional Commit. Before merging, the maintainer checks the title (the squash message) with `node scripts/check-commits.mjs --message ""`. 5. Fill in the checklist in the PR template. If performance is affected, attach the numbers from before and after. 6. Address review comments by adding commits. Contributed code is distributed under the same [MIT](https://github.com/CreatiCoding/zeropty/blob/main/LICENSE-MIT) or [Apache-2.0](https://github.com/CreatiCoding/zeropty/blob/main/LICENSE-APACHE) license as the repository. --- <!-- https://zeropty.pubdoc.dev/guide/design --> # Design: how we got to 10x **In one line: we exploit the fact that most of 1000 sessions are quiet.** Quiet screens are folded away so they use almost no memory, and busy screens batch their work so there is less of it. The numbers below were measured by turning each technique on and off, and the same evidence is recorded in the code comments. ## Overall architecture One engine owns every session. Depending on who runs the engine, it is either a standalone server (`zeropty serve`) or a library inside your own program. Either way, the command format is the same as tmux. ```mermaid flowchart LR subgraph clients["Command senders"] cli["zeropty CLI"] sh["zsh function · bash builtin"] node["client/zeropty.mjs"] att["zeropty attach"] end subgraph host["Process running the engine: zeropty serve or your program"] srv["server: JSON line socket"] eng["engine: sessions · screens · commands<br/>one thread, one kqueue"] emb["embed: Node module · C ABI · Rust"] end subgraph sessions["Sessions (no PTY)"] p1["program 1"] p2["program 2 ... 1000"] end clients -->|"Unix socket (~/.zeropty/mux.sock)"| srv srv --> eng emb -->|"function call (Node send-keys p50 0.06~0.07ms)"| eng eng -->|"stdin: socketpair"| sessions sessions -->|"stdout+stderr: the same socketpair"| eng ``` - A **session** is a process group launched with `/bin/sh -c command`. Its terminal is a single socketpair: stdin, stdout and stderr are all its one end, as they are one PTY under tmux. - For **programs that need a terminal**, `zeropty-tty` injects a shim with `DYLD_INSERT_LIBRARIES` so the socket looks like a terminal. - **Plugins** listen to session events and add commands. Separate processes attached over the socket, C libraries launched with `plugin-run` and callbacks inside the host process all go through the same registry ([Plugin API](/reference/plugin-api)). - The **engine** waits on a single kqueue for every session's output, process exits and timers. If the host registers its own fds on the same kqueue (`Engine::watch`, with `HOST_TAG` in the tag), a single event loop is all you need. ## 1. Sockets instead of PTYs tmux opens one PTY per window and so runs into the macOS PTY limit (511 by default, 999 at most). zeropty connects sessions with socket pairs, so there is no such limit. claude gets `shim.dylib` injected so it sees a terminal (the `claude-zeropty` wrapper). The shim intercepts `isatty`, `tcgetattr`, `ioctl(TIOCGWINSZ)`, `tcsetpgrp`, `readpassphrase`, `getpass` and others. What the PTY used to do (turning Enter into a newline, `C-c` into a signal, line editing, echo), the engine emulates when it sends keys. The same keys were checked against tmux ([Usage: line discipline](https://github.com/CreatiCoding/zeropty/blob/main/docs/USAGE.md#key-input-and-the-line-discipline)). When a program changes the terminal mode, the shim slips that fact into the output stream. So the engine knows exactly from which point "from now on" keys must be handled differently. ```mermaid sequenceDiagram participant H as host (send-keys) participant E as engine participant S as shim.dylib (inside the program) participant P as program (vim, claude) P->>S: isatty(1), ioctl(TIOCGWINSZ) S-->>P: "this is a terminal", size = cols rows from ZEROPTY_SIZE_FILE P->>S: tcsetattr(raw mode) S->>E: OSC zeropty-tty#59;flags in the output (not shown on screen) H->>E: send-keys C-c alt cooked mode (default for a new session) E->>P: SIGINT to the process group else raw mode (after the shim reports it) E->>P: the 0x03 byte as is end ``` When the window size changes (a human `attach`es from a terminal of a different size), the engine rewrites the size file and sends SIGWINCH. The shim reads that file every time it is asked. ## 2. A small screen structure We wrote our own screen parser and check that it produces the same results as the `vt100` crate, using recorded claude sessions and thousands of sets of random input. - A cell is 6 bytes (32 bytes in the `vt100` crate). Halving the cell size also halved CPU under load: with hundreds of sessions, every cell write is a cache miss. - A row holds cells only up to its last non-empty column. Empty space costs nothing. ```text Cell (6 bytes, packed) ┌───────────────────────────────┬──────────────────┐ │ ch: u32 │ attr: u16 │ │ code point + wide-char flag │ palette index │ │ (intern id if >1 code point) │ (color + font) │ └───────────────────────────────┴──────────────────┘ ``` ## 3. Folding screens away (memory) The longer a screen stays quiet, the smaller the form it is folded into; when output arrives or someone reads it, it unfolds again. ```mermaid stateDiagram-v2 direction LR state "Expanded rows: parsing" as live state "Packed rows: bytes per row" as packed state "Frozen: whole screen, about 1KB" as frozen state "Deep frozen: zstd, temp file" as deep [*] --> live live --> packed: rows touched by a read, right after it ends packed --> live: next output packed --> frozen: 250ms quiet frozen --> deep: 10s quiet frozen --> packed: output or capture-pane deep --> packed: output or capture-pane ``` - **Packed rows.** When a read finishes, the rows it touched are packed immediately (1 byte per ASCII character, repeated cells stored once). Even on a busy screen, only the row currently being parsed stays expanded. - **Freezing.** After 250ms of quiet, the whole screen is frozen into a single chunk of bytes. One claude screen goes from tens of KB to about 1KB. For sessions that print often, the wait grows up to 800ms so they don't keep freezing and thawing (with 400 sessions, growing it up to 4 seconds gave 3.3MB, 800ms gave 2.1MB, with the same CPU). - **Deep freezing.** After more than 10 seconds of quiet, the chunk is compressed again with zstd (to about half). These chunks are gathered in one unnamed temporary file, so they don't count against process memory. When each was allocated separately, 0.3MB of data for 400 sessions took up 0.84MB. - **Taming the allocator.** We use mimalloc v2 and return freed pages to the OS right away. With 400 busy claude sessions, v3 used 4.5MB and v2 3.5MB. Without returning pages, 400 idle sessions kept holding the 1.3MB they used while parsing. Buffers that are rebuilt often reuse a single chunk (fragmentation had cost 8.6KB per session; it dropped to about the 2.6KB of actual data). ## 4. Doing less work by batching it (CPU) This is the path one piece of output takes to reach the screen. Reads are deferred, but whoever reads the screen never waits. ```mermaid sequenceDiagram participant P as program in a session participant K as kqueue participant E as engine participant C as capture-pane caller P->>K: output (written to the socketpair) K->>E: readable (edge-triggered) Note over E: collect until the 300ms boundary<br/>this session's events are off meanwhile alt 16KB or more piled up, or a human is attached E->>E: read right away end C->>E: capture-pane E->>E: read and parse pending output first E-->>C: current screen E->>E: read everything at the boundary → fast-path parse → pack rows ``` - **One thread and one kqueue handle every session.** There is no thread or process per session. - **Batched reads.** Output is not read the moment it arrives; it is collected until a 300ms boundary and read all at once. Sessions that wake at the same moment are handled in a single wakeup. With 400 claude sessions, CPU was 5.2% at 100ms and 3.7% at 300ms. In exchange, `capture-pane` reads the piled-up output first and then answers, so readers of the screen never see stale data. If a human is attached, or the pipe fills up (16KB), output is read immediately. - **Fast paths.** Runs of ASCII, runs of UTF-8 (claude's borders, Korean text) and complete CSI sequences, which make up most output, are written straight to the screen without going through the general parser. The ASCII fast path alone halved the parsing cost. - **The output channel is a socketpair, not a pipe.** Once about 250 macOS pipes have grown, the rest are stuck at 512 bytes, and with 1000 sessions 41% of the output was stuck inside the programs. Giving each session its own 64KB socket buffer lets everything flow. - **Bulk output is parsed after it has all arrived.** When the pipe fills, everything is read first until the program stops. The time for 5.4MB of output to finish went from 17ms to \~3ms. - **Rows that will be discarded are not packed.** Rows scrolling off the screen are packed together when the write ends, and rows pushed out of the scrollback in the meantime are discarded without packing. Parsing 5.4MB went from 16.5ms to about 9ms. ## 5. Not starting processes (latency) Most of the cost of a single command is process startup (1.3ms even for an empty C program). | How the command is sent | Work per command | Time per command | |---|---|---| | tmux CLI | start process → connect to server → execute → process exits | 6.04\~6.33ms | | zeropty zsh function | connect to the socket from inside the shell, one line of JSON (no process) | 0.11\~0.17ms | | zeropty bash builtin | connect to the socket from inside the shell, one line of JSON (no process) | 0.041\~0.075ms | | zeropty embedded | function call | 0.06\~0.07ms in Node (`send-keys` p50) | ```mermaid flowchart TB subgraph tmux["tmux CLI: 6.04~6.33ms per command"] direction LR t1["fork + exec tmux"] --> t2["connect to server"] --> t3["execute"] --> t4["process exits"] end subgraph zsh["zeropty zsh function · bash builtin: 0.041~0.17ms"] direction LR z1["one JSON line to the socket from inside the shell"] --> z2["engine"] end subgraph emb["zeropty embedded: 0.06~0.07ms (Node)"] direction LR e1["function call in your program"] --> e2["engine (same process)"] end tmux ~~~ zsh ~~~ emb ``` - **Socket.** One connection carries many commands (the Node client). - **Embedded.** Putting the engine inside your process turns commands into function calls. In Node, one `send-keys` takes p50 0.06\~0.07ms and `capture-pane` 0.06\~0.07ms (busy session 0.10\~0.17ms). Most of that time is the idle Node thread waking up (\~40\~50µs); called back to back without idling, it is 2.6µs per call ([BENCHMARKS](/guide/benchmarks)). With no server process, there is also no floor of one empty process's memory (0.865MB). If even the engine thread is dropped and the host's event loop drives the engine (polled), 200 idle sessions go from 0.68\~0.69MB to 0.39MB. Commands like `capture-pane` with nothing to read, or `send-keys`, are handled right on the calling thread without waking the engine thread. - **zsh function, bash builtin.** zsh uses its built-in socket module, and bash a C function loaded with `enable -f`, to talk to the server directly from inside the shell. A tmux command's 6.04\~6.33ms becomes 0.11\~0.17ms in zsh and 0.041\~0.075ms in bash (`has-session` and `send-keys`, zsh at p50; a 2000-line `capture-pane` is 0.50\~0.55ms in zsh and 0.114\~0.138ms in bash). ## 6. Only measurements decide Every change was measured interleaved with tmux, and its cause confirmed with a profiler, before it went in. Several "obvious" causes (kernel time, repeated freezing, the allocator) turned out wrong, and a profile or a counter showed the real one. Techniques whose benefit exists only on paper (putting the heap in a file: the footprint shrinks but RAM use stays the same, and it adds 10MB of disk writes per minute) were left out. So were techniques that only raise benchmark scores. The "claude history dump" workload is a synthetic load that resends the same 15KB unchanged every 2 seconds, but when a real claude recording (40 seconds) is split into 300ms reads, the same output repeats 0% of the time. So "skip redraws that don't change the screen" would raise only this workload's CPU multiplier (10.2\~11.9×) and do nothing for real use, and it was left out. Methodology and every number: [Benchmarks](/guide/benchmarks) ## Code map | File | What it does | |---|---| | `src/term/` | Screen parser (6-byte cells, packed rows, freezing, deep freezing). Line-for-line the same meaning as the `vt100` crate. Pure code with no I/O | | `src/sys/` | The OS boundary: kqueue, fds, processes, time, the attaching person's terminal (`zeropty::tty`). libc I/O is called only from here | | `src/engine/` | The engine: sessions, every command (the `COMMANDS` table in `commands.rs`), `wait-pane`, attach, moving sessions (`handover.rs`). It touches the OS only through the `Poller`, `Clock` and `Programs` traits, so it is unit-tested with fakes | | `src/protocol.rs` | Bytes on the socket: request and answer lines, attach frames. Shared by the server and the CLI | | `src/server.rs` | Socket server: clients, `attach`, `wait-pane`, session handover. Borrows the engine | | `src/host.rs` | The order of one turn of the host loop (events → reads → attach output and plugins → filter changes). Shared by every host | | `src/embed.rs` | The engine inside a host: on its own thread (`Embedded`) or on the host loop (`Polled`). Bindings call it only through the `Frontend` trait | | `src/plugin.rs`, `src/plugin/` | Plugins ([Plugin API](/reference/plugin-api)): the registry (`Plugins`) is a `host::Service` that runs after the server. The engine collects only the kinds of session events plugins asked for. With no plugins, it costs one atomic read per command | | `src/main.rs`, `src/cli/` | The `zeropty` program: standalone server, CLI, attach, `plugin-run`, `--help`, `--version` | | `shim/shim.c` | The shim that intercepts `isatty` and friends | | `bindings/c`, `bindings/node` | C ABI (`zeropty.h`) and Node module (N-API) | | `client/` | Node clients (`zeropty.mjs` for the server, `embedded.mjs` for embedded) | | `shell/` | zsh function, bash builtin | | `tests/` | `vt100` comparison (recordings + random), server, embedded, moving sessions | | `bench/` | Benchmark scripts | What each layer is responsible for and which invariants it keeps is documented in each module's `//!` docs (rustdoc). Rust API docs: [Docs](https://github.com/CreatiCoding/zeropty/blob/main/docs/README.md#rust-api-docs). --- <!-- https://zeropty.pubdoc.dev/guide/embed --> # Embed zeropty in C or Rust By the end of this guide, you run sessions inside your own C or [Rust](https://www.rust-lang.org) program, with no server process. You send the same commands as the CLI, choose between an engine thread and your own loop, and open a socket so the CLI can reach your sessions. ## Before you begin - A Mac with the Xcode Command Line Tools (`xcode-select --install`). - **C:** a build from source. `./build.sh` puts `libzeropty_c.dylib` and `zeropty.h` in `dist/`. The release archive does not carry them. - **Rust:** Rust 1.88 or later. The crate is not on crates.io, so you add it as a git dependency; `./build.sh` is not needed. - To start programs that need a terminal (claude, vim), you also need `dist/zeropty-tty`, which is not part of the crate. For details, see [Run claude and other terminal programs](/guide/terminal-programs). ## 1. Add zeropty to your build ::: code-group ```sh [C (Terminal)] git clone https://github.com/CreatiCoding/zeropty && cd zeropty ./build.sh # dist/libzeropty_c.dylib and dist/zeropty.h ``` ```toml [Rust (Cargo.toml)] [dependencies] zeropty = { git = "https://github.com/CreatiCoding/zeropty" } ``` ::: In Rust, add `rev = "COMMIT"` to stay on one commit. Replace `COMMIT` with the commit hash. ## 2. Start an engine and send commands An **embedded** engine takes the same commands as the CLI and returns the same `code`, `out` and `err`. In C, the request and the reply are the socket protocol's JSON lines. In Rust, they are an `argv` and a `Reply`. ::: code-group ```c [examples/c/hello.c] #include <stdio.h> #include <string.h> #include <unistd.h> #include "zeropty.h" /* Runs one command and prints its reply. */ static void run(zeropty *zp, const char *request) { char *reply = zeropty_command(zp, request); printf("%s\n -> %s\n", request, reply); zeropty_free(reply); } int main(void) { zeropty *zp = zeropty_open(NULL); /* or "{\"history\": 2000}" */ if (!zp) return 1; run(zp, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"c\",\"-x\",\"40\",\"-y\",\"4\",\"exec cat\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"send-keys\",\"-t\",\"=c:\",\"-l\",\"hi from C\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"send-keys\",\"-t\",\"=c:\",\"Enter\"],\"cwd\":\"/\"}"); usleep(100 * 1000); /* time for cat to print the line back */ run(zp, "{\"argv\":[\"capture-pane\",\"-p\",\"-t\",\"=c:\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"kill-server\"],\"cwd\":\"/\"}"); zeropty_close(zp); return 0; } ``` ```rust [examples/embed.rs] use std::time::{Duration, Instant}; use zeropty::embed::Embedded; use zeropty::engine::{Options, Reply}; fn main() { let zp = Embedded::start(Options::default()); let run = |args: &[&str]| -> Reply { zp.command(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>(), "/") }; // A session is a program on pipes: `sh` here, any program works (`exec cat`, a coding agent...). let r = run(&["new-session", "-d", "-s", "demo", "-x", "60", "-y", "10", "sh"]); assert_eq!(r.code, 0, "{}", r.err); // Keys go through the engine's line discipline, as through a PTY: Enter ends the line. run(&["send-keys", "-t", "=demo:", "-l", "echo \"answer: $((6 * 7))\""]); run(&["send-keys", "-t", "=demo:", "Enter"]); // capture-pane reads the output pending first, so the screen is never stale. let start = Instant::now(); let screen = loop { let screen = run(&["capture-pane", "-p", "-t", "=demo:"]).out; if screen.contains("answer: 42") || start.elapsed() > Duration::from_secs(5) { break screen; } std::thread::sleep(Duration::from_millis(10)); }; print!("{}", screen.trim_end_matches('\n')); println!(); // Every session's name and size, in tmux's format language. print!("{}", run(&["list-sessions", "-F", "#{session_name} #{window_width}x#{window_height}"]).out); // kill-server ends the sessions; dropping `zp` stops the engine's thread. run(&["kill-server"]); } ``` ::: `zeropty_open` (Rust: `Embedded::start`) gives the engine its own **engine thread**. You may call commands from any thread: reading the screen, sending keys and queries run on the calling thread, and everything else goes to the engine thread. In C, free every reply with `zeropty_free`, never with `free()`. The function call has no `wait-pane`, so these examples read the screen in a loop. Step 3 shows the polled engine's way to wait. ## 3. Drive the engine from your own loop A **polled** engine has no thread of its own. Your event loop waits on the engine's fd and calls `process` when it is readable or its timeout passes. Make every call from that loop's thread. With 400 idle sessions, polled mode took memory from 0.74MB to 0.45~0.46MB. ::: code-group ```c [app.c] zeropty *a = zeropty_open_polled(NULL); for (;;) { // host loop: call zeropty_process when the engine fd is readable or the timeout expires struct pollfd p = { zeropty_fd(a), POLLIN, 0 }; poll(&p, 1, (int)zeropty_timeout_ms(a)); zeropty_process(a); } ``` ```rust [examples/polled.rs] let mut zp = Polled::start(Options::default()); let r = zp.command(&argv(&["new-session", "-d", "-s", "job", "sh -c 'sleep 0.5; echo build done; exec cat'"]), "/"); assert_eq!(r.code, 0, "{}", r.err); // wait-pane -t job -m 'build done' -T 5000, without the command name. let wait = zp.engine().wait_start(&argv(&["-t", "=job", "-m", "build done", "-T", "5000"])).unwrap_or_else(|r| panic!("{}", r.err)); let reply = loop { if let Some(reply) = zp.engine().wait_check(&wait) { break reply; } let mut p = libc::pollfd { fd: zp.fd(), events: libc::POLLIN, revents: 0 }; unsafe { libc::poll(&mut p, 1, zp.timeout_ms().min(i32::MAX as u64) as i32) }; zp.process(); }; zp.engine().wait_end(&wait); ``` ::: In Rust, `Engine::wait_start` and `wait_check` give you `wait-pane` inside the loop, with no polling of the screen. The session ends with `exec cat` so it stays alive after printing; a session that exits right after its last output can be reported as ended before that output is matched. ## 4. Open a socket for the CLI The **server plugin** runs the same server as `zeropty serve` inside your program. The CLI, `attach`, the shell function and the Node client then reach your sessions, and `wait-pane` works over that socket: ::: code-group ```c [app.c] if (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* could not open it */ } ``` ```sh [Terminal] ZEROPTY_SOCK=/tmp/myapp.sock zeropty capture-pane -p -t '=w:' ZEROPTY_SOCK=/tmp/myapp.sock zeropty attach -t w ``` ::: - The path must be 103 bytes or less. If a server already answers there, `zeropty_listen` returns -1. - `kill-server` from the socket ends only the sessions; your program keeps running. - Sessions still end with your program. `push-sessions` them to a standalone server before exiting to keep them ([Move sessions](/guide/moving)). ## Verify Build and run each example from the zeropty folder (the Rust excerpt in step 3 is shortened from `examples/polled.rs`): ::: code-group ```sh [C] cc examples/c/hello.c -Idist -Ldist -lzeropty_c -o /tmp/zeropty-hello DYLD_LIBRARY_PATH=dist /tmp/zeropty-hello ``` ```sh [Rust] cargo run --release --example embed cargo run --release --example polled ``` ::: The C program prints each request followed by its reply. You should see `{"code":0,"out":"","err":""}` for the first three, and the `capture-pane` reply holds `hi from C` twice: once echoed as typed, once printed back by `cat`. `embed` prints the typed line, the answer and the session list: ```text echo "answer: $((6 * 7))" answer: 42 demo 60x10 ``` `polled` prints the wait result (`NNN` is the milliseconds) and the screen: ```text wait-pane: code 0 after NNN ms build done ``` ## Troubleshooting | Symptom | Fix | |---|---| | `Library not loaded: … libzeropty_c.dylib` | Set `DYLD_LIBRARY_PATH=dist`, or link with `-Wl,-rpath,"$PWD/dist"`. | | `zstd-sys` fails to build | Install the Xcode Command Line Tools. | | Sessions vanish when the program exits | They live in your process. `push-sessions` them to a server first. | | A call from another thread misbehaves on a polled engine | Every call must come from the loop's thread. Use `zeropty_open` for calls from many threads. | | A plugin command holds your loop | Start it with `zeropty_command_start` and collect it with `zeropty_call_poll`. See [Write a plugin](/guide/plugins). | ## Next steps - [C ABI](/reference/c-abi) for every function, type and macro of the C ABI. - [Rust API](/reference/rust-api) for the Rust crate and building its rustdoc. - [Four ways to drive zeropty](/guide/concepts/interfaces) to compare embedding with the socket and the CLI. - [Write a plugin](/guide/plugins) to add commands to an embedded engine. --- <!-- https://zeropty.pubdoc.dev/guide/faq --> # Frequently asked questions **What it is** - [Does it replace tmux?](/guide/faq#does-it-replace-tmux) - [Can I use it without claude?](/guide/faq#can-i-use-it-without-claude) - [Does it run on Linux or Windows?](/guide/faq#does-it-run-on-linux-or-windows) - [Why does the README list only results of 10x or more?](/guide/faq#why-does-the-readme-list-only-results-of-10x-or-more) **Using it** - [How do I see the help?](/guide/faq#how-do-i-see-the-help) - [What is missing when I install with `cargo install`?](/guide/faq#what-is-missing-when-i-install-with-cargo-install) - [Can I use my tmux scripts as they are?](/guide/faq#can-i-use-my-tmux-scripts-as-they-are) - [Can I use it without a server process?](/guide/faq#can-i-use-it-without-a-server-process) - [How do I know when an agent is done?](/guide/faq#how-do-i-know-when-an-agent-is-done) - [Doesn't `capture-pane` return a screen that is 300ms old?](/guide/faq#doesnt-capture-pane-return-a-screen-that-is-300ms-old) - [Can I upgrade without losing sessions?](/guide/faq#can-i-upgrade-without-losing-sessions) - [Can my sessions survive a restart of my app?](/guide/faq#can-my-sessions-survive-a-restart-of-my-app) **Troubleshooting** - [A program behaves differently because it is "not a terminal"](/guide/faq#a-program-behaves-differently-because-it-is-not-a-terminal) - [`wait-pane -m` returns at once](/guide/faq#wait-pane--m-returns-at-once) - [`wait-pane` ends with `session ended` although the text appeared](/guide/faq#wait-pane-ends-with-session-ended-although-the-text-appeared) - [Input stops arriving after I send `C-d`](/guide/faq#input-stops-arriving-after-i-send-c-d) - [`-t =work:` behaves oddly in zsh](/guide/faq#-t-work-behaves-oddly-in-zsh) - [I get `no server running on ...`](/guide/faq#i-get-no-server-running-on-) - [Starting many sessions fails](/guide/faq#starting-many-sessions-fails) - [How do I stop the server?](/guide/faq#how-do-i-stop-the-server) --- ## Does it replace tmux? **Not as a terminal multiplexer for people.** zeropty focuses on starting programs (coding agents in particular), reading their screens, sending them keys and waiting until they finish. It has no window splits, status line, copy mode or configuration file. In return, for that job it takes the same commands as tmux, is 10x or better on memory, CPU and latency across the whole measured range in 11 of the 15 items measured (4 are under 10x) ([benchmarks](/guide/benchmarks)), and has no PTY limit on the number of sessions. When a person wants to look in, `zeropty attach` connects to a session. ## Can I use it without claude? Yes. A session can run any program (`sh`, `cat`, `python3`, a build command ...). The tests and the [examples](https://github.com/CreatiCoding/zeropty/blob/main/docs/README.md#examples) all run without claude. `claude-zeropty` is only a small wrapper that starts claude without a terminal. ## Does it run on Linux or Windows? macOS and Linux (x86_64; glibc 2.29 or later, kernel 5.3 or later), not Windows. The engine's queue is kqueue on macOS and epoll on Linux, behind the same types (`src/sys/kq.rs`, `src/sys/epoll.rs`), and the shim that fools programs which need a terminal loads with `DYLD_INSERT_LIBRARIES` on macOS and `LD_PRELOAD` on Linux (`shim.so`). On Linux a statically linked program never loads the shim and sees no terminal. ## Why does the README list only results of 10x or more? The project's goal is "10x better than tmux on every item", and the README shows only the items that have reached it. The items below 10x are not hidden: [benchmarks](/guide/benchmarks) lists every measurement, marked ⛔ where it falls short, with the reason for the low end. Numbers are written as the measured range, never rounded. ## How do I see the help? `zeropty --help` shows every command and option on one screen (`-h` and `help` do the same). For one command, use `zeropty help send-keys`. It needs no server and does not start one. Each option's details and the key names are in [Usage](/reference/commands#command-reference). The command names and options are tmux's, so most of tmux's documentation applies as well ([feature comparison with tmux](/reference/tmux-compatibility#feature-comparison-with-tmux)). ## What is missing when I install with `cargo install`? `cargo install --git https://github.com/CreatiCoding/zeropty` puts a single Rust binary, `zeropty` (the server and the CLI), in `~/.cargo/bin`. That alone starts the server, runs programs that need no terminal (`sh`, `python3`, build commands) as sessions, and gives you `send-keys`, `wait-pane`, `capture-pane` and `attach`. This is what it leaves out. `cargo install` installs only a package's executables, and `./build.sh` builds or copies the files below separately. | Left out | Why | Without it | |---|---|---| | `shim.dylib` | A C file (`shim/shim.c`) built separately with `clang` | Programs that need a terminal (claude, vim) decide they are "not on a terminal" | | `zeropty-tty`, `claude-zeropty` | Shell scripts that look for `shim.dylib` in their own folder | claude cannot be started without a terminal | | `libzeropty_c.dylib`, `zeropty.h`, `zeropty.node` | Libraries built by other crates of the workspace (`bindings/`) | No C or Node embedding (Rust embedding works through a git dependency) | | `zeropty_bash.dylib` | A C file (`shell/zeropty_bash.c`) built separately | No bash builtin (the regular CLI still works) | If you need any of these, build with `git clone` and `./build.sh` as in [Quick start](/guide/getting-started) and use `dist/`. ## Can I use my tmux scripts as they are? Mostly, by replacing `tmux` with `zeropty`. Commands that handle programs, such as `new-session`, `send-keys`, `capture-pane`, `list-sessions -F`, `display-message -p`, `load-buffer`, `paste-buffer` and `pipe-pane`, take the same arguments. The differences: - There are no windows or window splits. One session runs one program (no `split-window`, no `new-window`). - Target names must match exactly (no prefix matching as in tmux). - Format conditionals (`#{?...}`) and modifiers (`#{t:...}`) are not supported. - An unknown option is an error, not ignored. Full comparison: [Usage: feature comparison with tmux](/reference/tmux-compatibility#feature-comparison-with-tmux) ## Can I use it without a server process? Yes. Put the engine inside your own process (Node module, C ABI, Rust crate). Commands become function calls, so one `send-keys` from Node takes 0.06\~0.07ms (9.03\~9.56ms when tmux runs for each command). If needed, your program can also open a socket (`listen`) so the CLI and `attach` reach it too. [Usage: embedded](/reference/rust-api#inside-your-process-embedded) ## How do I know when an agent is done? Use `wait-pane`. It answers when text appears on the screen (`-m`), when output has paused for a given time (`-q`), or both. The server checks only when output arrives, and only the rows that changed, so nothing polls. ```sh zeropty wait-pane -t '=fix-1:' -q 5000 -T 600000 # until output pauses for 5 s, at most 10 minutes ``` To find which of many sessions is waiting for input, use `grep-panes` once. ## Doesn't `capture-pane` return a screen that is 300ms old? No. To save CPU the engine reads output in batches every 300ms, but `capture-pane` (and `grep-panes`, `wait-pane`) first reads any pending output and then answers. A reader always gets the latest screen. Output also goes straight to a person watching with `attach`. ## Can I upgrade without losing sessions? Yes. Run `zeropty restart-server` with the new binary: the new server takes over every running session and then the socket. The programs never stop and keep their pids. [Usage: moving sessions](/reference/socket-protocol#moving-sessions) ## Can my sessions survive a restart of my app? An embedded engine's sessions end with the app. Before the app exits, hand them to a standalone server with `push-sessions -s <server socket>`, and take them back after it starts again with `pull-sessions -s <server socket>`. ## A program behaves differently because it is "not a terminal" A session's terminal is a socket, not a PTY, so `isatty()` is false. Start the program through `zeropty-tty`: ```sh zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt" ``` System programs in `/usr/bin` run from a copy with only the signature changed. Programs that need special privileges, such as `top` or `ps`, do not work this way. [Usage: zeropty-tty](https://github.com/CreatiCoding/zeropty/blob/main/docs/USAGE.md#programs-that-need-a-terminal-zeropty-tty) ## `wait-pane -m` returns at once Typed text is echoed to the screen as with a PTY. If you send `send-keys -l 'echo done'` and then run `wait-pane -m done`, the `done` in the typed line matches at once. Wait for text that only the result prints, not the typed line. For example, send `echo "exit: $?"` and wait for `-m 'exit: 0'`. ## `wait-pane` ends with `session ended` although the text appeared When a session prints the text being waited for and ends right away, the engine sometimes reports the end of the session before it has checked that output (a known issue; it reproduces about 5 times in 20). You then get `wait-pane: session ended` and exit code 1. Keep the session alive after the awaited text appears: ```sh zeropty new-session -d -s build "sh -c 'sleep 1; echo build done; exec cat'" # exec cat at the end zeropty wait-pane -t build -m 'build done' -T 10000 zeropty kill-session -t build ``` The usual flow, starting a long-running program such as a shell (`sh`, `zsh`) or claude as the session and giving it work with `send-keys` ([Quick start](/guide/getting-started)), does not have this problem. ## Input stops arriving after I send `C-d` The session's socket has no way to signal "EOF once" the way a PTY does, so `C-d` on an empty line shuts the input. After that, nothing more can be sent to that session. Unless you mean to end the program, send `C-c` or the program's own quit command instead of `C-d`. ## `-t =work:` behaves oddly in zsh zsh turns `=word` into the path of a command (`=` expansion). Quote it, as in `'=work:'`. The same goes for tmux. ## I get `no server running on ...` No server is listening on that socket. The first `new-session` starts the server. To talk to a server on another socket (for example an embedded app's `listen`), name the socket: `ZEROPTY_SOCK=<socket> zeropty ...`. ## Starting many sessions fails There is no PTY limit, but macOS does limit the number of processes per user. One claude session is about 4 processes. ```sh sysctl kern.maxprocperuid # process limit per user ps -U $(id -u) | wc -l # how many processes you have now ``` Often leftover test or benchmark servers are using up the limit. Send `kill-server` to their sockets to clean up. ## How do I stop the server? ```sh zeropty kill-server ``` The server sends SIGHUP to every session's process group and exits. For a server on another socket, give `ZEROPTY_SOCK` as well. --- <!-- https://zeropty.pubdoc.dev/guide/from-tmux --> # Coming from tmux By the end of this guide, you run an existing [tmux](https://github.com/tmux/tmux) script on zeropty, replace its polling loop with `wait-pane`, and know the few differences that make a script behave differently. ## Before you begin - A Mac with zeropty on your `PATH`. For details, see [Install and platform support](/guide/install). - A shell script that drives programs through tmux: `new-session`, `send-keys`, `capture-pane` and the like. - If the script starts programs that need a terminal (claude, vim), `zeropty-tty` from the release archive or `./build.sh`. ## 1. Check what maps one to one zeropty takes tmux's command names, aliases and arguments for driving programs. The format variables were checked against tmux on the same sessions, and the key bytes against tmux 3. | Works the same | Notes | |---|---| | `new-session`, `kill-session`, `has-session`, `list-sessions`, `rename-session` | `new-session` accepts and ignores `-n -E -D -X` | | `send-keys`, `paste-buffer`, `load-buffer` | Same [key names](/reference/keys-and-formats#key-names) | | `capture-pane` | Only `-p -e -J -N -S -E -b -q`; other options are errors | | `display-message -p`, `list-sessions -F` | 25 [format variables](/reference/keys-and-formats#format-variables) | | `pipe-pane`, `resize-window`, `set-environment -g` | No `pipe-pane -I` | | `attach` | Detach with `Ctrl-b d`; `-r` is read-only | ## 2. Check what does not exist zeropty keeps programs running, reads their screens and sends them keys. Features for people sharing a terminal are not there: - **No windows or panes.** One session runs one program. There is no `split-window` or `new-window`. `window_index` and `pane_index` are always `0`. - **No status line, copy mode, key bindings, config file or hooks.** - **No format conditionals or modifiers** (`#{?...}`, `#{t:...}`). Unknown variables become an empty string, as in tmux. - **No control mode** (`-C`). Programs use the JSON socket protocol or the Node client instead. For the full table, see [tmux compatibility](/reference/tmux-compatibility). ## 3. Switch the script Replace `tmux` with `zeropty`. A one-line variable keeps the change small: ```sh [agents.sh] #!/bin/sh set -e T=zeropty # was: T=tmux $T new-session -d -s fix-1 -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" $T send-keys -t '=fix-1:' -l 'Fix the failing test' $T send-keys -t '=fix-1:' Enter ``` Replace `~/proj` with your repository. Programs that need a terminal must start through `zeropty-tty`; for claude, `claude-zeropty` does it. For details, see [Run claude and other terminal programs](/guide/terminal-programs). The first `new-session` starts the server if none is running, as tmux does. ## 4. Replace polling with wait-pane A tmux script learns that an agent is done by calling `capture-pane` in a loop. zeropty answers when it happens: ::: code-group ```sh [Before (tmux)] until tmux capture-pane -p -t '=fix-1:' | grep -q 'All tests pass'; do sleep 0.2 done ``` ```sh [After (zeropty)] zeropty wait-pane -t '=fix-1:' -m 'All tests pass' -T 600000 ``` ::: `-m` waits for text, `-q` waits until output pauses, and `-T` sets a timeout in milliseconds. The server checks only when output arrives (detection p50 1ms, p95 2ms). To see which of many sessions shows a text, use `grep-panes` once instead of a `capture-pane` per session. For details, see [Screens, keys and waiting](/guide/concepts/screens). ## 5. Fix the differences that break scripts | Difference | What to change | |---|---| | **Targets match exactly.** There is no prefix matching: `-t fix` does not find `fix-1`. | Use full names. Write targets as `'=name:'`. | | **Unknown options are errors** (`send-keys: unknown flag -X`), not ignored. | Remove options zeropty does not list. They used to be ignored silently, which changed results. | | **The server keeps the environment of the process that started it.** | Pass variables with `new-session -e VAR=value`, or `set-environment -g VAR value` for every new session. | | **`=name:` in zsh** turns into a command path (`=` expansion). | Quote it: `'=fix-1:'`. The same applies to tmux. | | **`C-d` closes a session's input for good**, since the socket cannot signal EOF just once. | Send `C-c` or the program's quit command unless you mean to end it. | | **No PTY.** A program run directly sees no terminal. | Start it with `zeropty-tty`. | `TMUX` and `TMUX_PANE` are removed from a session's environment, so a program in a session does not think it runs inside tmux. Exit codes follow tmux: 0 for success, 1 for failure. For details, see [Exit codes](/reference/keys-and-formats#exit-codes). ## Verify Run the switched script and wait for the agent, or use this check with `sh` in place of claude: ```sh [Terminal] zeropty new-session -d -s demo -x 80 -y 6 sh zeropty send-keys -t '=demo:' -l 'echo "exit: $?"' zeropty send-keys -t '=demo:' Enter zeropty wait-pane -t '=demo:' -m 'exit: 0' -T 10000 zeropty list-sessions ``` You should see the session in tmux's own list format, with the creation time in place of `...`: ```text demo: 1 windows (created ...) ``` `zeropty capture-pane -p -t '=demo:'` shows the typed line and `exit: 0`. ## Troubleshooting | Symptom | Fix | |---|---| | `can't find session` or `can't find pane` for a short name | Targets match exactly. Use the full name. | | `unknown flag` on an option tmux accepted | zeropty does not have it. Check [Commands](/reference/commands#command-reference). | | `split-window` or `new-window` fails | There are no windows. Start one session per program. | | A program behaves as if it is "not a terminal" | Start it with `zeropty-tty`. | | A variable from your shell is missing in a new session | The server has the environment it started with. Use `-e` or `set-environment -g`. | | A format prints an empty string | The variable is unknown, or uses a conditional or modifier. See [Format variables](/reference/keys-and-formats#format-variables). | ## Next steps - [tmux compatibility](/reference/tmux-compatibility) for the feature-by-feature table. - [Drive agents from the shell](/guide/shell) for `wait-pane`, `grep-panes` and the fast path. - [FAQ: Can I use my tmux scripts as they are?](/guide/faq#can-i-use-my-tmux-scripts-as-they-are) --- <!-- https://zeropty.pubdoc.dev/guide/moving --> # Move sessions and upgrade the server By the end of this guide, you upgrade the zeropty server while its programs keep running, and you move running sessions between two servers. You also know what travels with a session and what stays behind. ## Before you begin - A Mac with zeropty installed and a server with at least one session. Commands below use a session named `work`; replace `work` with your session's name. - For an upgrade, the new `zeropty` binary: a newer release folder or a fresh `./build.sh`. For details, see [Install and platform support](/guide/install). - Moving sessions keeps programs alive, but it is not a backup. A session ends when its program exits, wherever it lives. ## 1. Note what you will check Record the program's pid and the server's version before you change anything: ```sh [Terminal] zeropty display -p -t '=work:' '#{pane_pid}' zeropty display -p '#{version}' ``` `#{pane_pid}` is the program's pid, and `#{version}` is the version of the server that answers. `zeropty -V` prints the version of the CLI you ran, which may differ. ## 2. Upgrade with restart-server Run `restart-server` from the **new** binary: ```sh [Terminal] zeropty restart-server ``` A new server starts from the `zeropty` binary you just ran, takes over every session, then takes over the socket. The programs never stop and keep their pids. Commands that arrive during the few ms of the switch go to the old server. If the new binary is not first on your `PATH`, run it by its path, for example `./zeropty-0.1.6-darwin-universal/zeropty restart-server`. ::: warning The new server has your shell's environment `restart-server` starts the new server from the shell that runs it. Sessions created afterwards get that shell's environment, and values set with `set-environment -g` are dropped: set them again. Running sessions keep their own environment. `ZEROPTY_PLUGIN_DIR` and `ZEROPTY_SOCK` also come from this shell. ::: ## 3. Move sessions between servers `pull-sessions` brings sessions from another server into the one your `zeropty` talks to. `push-sessions` sends them the other way. Name the other server by its socket with `-s`: ```sh [Terminal] zeropty pull-sessions -s ~/.zeropty/other.sock # all of the other server's sessions zeropty pull-sessions -s ~/.zeropty/other.sock work build # only these zeropty push-sessions -s ~/.zeropty/other.sock work # this server's work to the other server ``` Replace `~/.zeropty/other.sock` with the other server's socket. If no server is running on your side, `pull-sessions` starts one. The sending side releases only the sessions the receiver confirmed it took. If anything fails midway, sessions stay where they were. ## 4. Keep an embedded app's sessions across restarts An embedded engine's sessions end with your app. The same commands work there, so hand them to a server before exiting and take them back after starting: ```js [app.mjs] import { zeropty } from './client/embedded.mjs' const server = `${process.env.HOME}/.zeropty/mux.sock` await zeropty(['pull-sessions', '-s', server]) // on start: take the sessions back // ... run your app ... await zeropty(['push-sessions', '-s', server]) // before exit: hand them to the server ``` For the embedding side, see [Use zeropty from Node.js](/guide/node) and [Embed zeropty in C or Rust](/guide/embed). ## 5. Know what moves | Moves with the session | Does not move | |---|---| | The running program and its pid | A `pipe-pane` pipe: it is closed when the session moves | | The screen and scrollback, as bytes | Values from `set-environment -g`, on `restart-server` | | The session's fds, by fd passing (SCM_RIGHTS) | A session whose name already exists on the other side | | | The rest of an escape sequence that was half received at the moment of the move: it is printed as text | Plugins are not handed over. With a plugin directory, the new server starts its own plugins after it takes over the socket, and for tens of ms plugin commands may return `unknown command`. Plugins see the move as events: `session-exited` with the reason `handed-over` on the sending side, `session-created` on the receiving side. Under the hood, `pull-sessions`, `push-sessions` and `restart-server` use two internal requests on the socket, `export-sessions` and `import-sessions`. You rarely send them yourself, and the record format may change between versions. For details, see [Socket protocol: export-sessions, import-sessions](/reference/socket-protocol#socket-protocol-export-sessions-import-sessions). ## Verify Run the commands from step 1 again: ```sh [Terminal] zeropty display -p -t '=work:' '#{pane_pid}' zeropty display -p '#{version}' ``` You should see the same pid as before and the new version. For example, after upgrading to 0.1.5 a program with pid 48213 reads: ```text 48213 0.1.5 ``` `zeropty capture-pane -p -t '=work:'` shows the same screen as before the upgrade. ## Troubleshooting | Symptom | Fix | |---|---| | The move prints `duplicate session: NAME` and exits with 1 | A session with that name exists on the other side. Rename one with `rename-session` and move it again. | | `session record of another version` | The two sides run zeropty versions whose session records differ. Run both on the same version. | | New sessions miss a variable after `restart-server` | The new server has the environment of the shell that ran it. Set the variable there, or use `set-environment -g` again. | | A plugin command says `unknown command` right after the upgrade | The new server is still starting its plugins. Try again after a moment. | | `C-d` no longer reaches a session after a downgrade | Sessions started by 0.1.2 or later should not be handed back to 0.1.1 or earlier. | ## Next steps - [Sessions and the server](/guide/concepts/sessions) for why a session can change processes. - [Server and moving sessions](/reference/commands#server-and-moving-sessions) for every option. - [Moving sessions](/reference/socket-protocol#moving-sessions) for what happens on the socket. --- <!-- https://zeropty.pubdoc.dev/guide/node --> # Use zeropty from Node.js By the end of this guide, you run an agent host from [Node.js](https://nodejs.org): you start a session, give it work, wait for it and read its screen. You do it first over the server's socket, then with the engine inside your Node process, by changing one import. ## Before you begin - A Mac with zeropty built from source (`git clone` and `./build.sh`). The Node client loads `dist/zeropty.node` and runs `dist/zeropty`; the release archive carries neither `zeropty.node` nor the client. For details, see [Install and platform support](/guide/install). - Node.js with N-API 6 or later (`napi6`). The examples in the repository are run with Node 18 or later. - Run the commands from the zeropty folder. The imports below use `./client/...`; from your own project, import the files by their path in that folder. ## 1. Pick a client The two modules take the same calls and return the same result, `{ ok, code, out, err }`: | Module | Where sessions live | One `send-keys` | Use it when | |---|---|---|---| | `client/zeropty.mjs` | The zeropty server | 0.24~0.26ms | Sessions must outlive your app, or several programs share them | | `client/embedded.mjs` | Your Node process (**embedded**) | 0.06~0.07ms | Your app owns the sessions | For how the four interfaces compare, see [Four ways to drive zeropty](/guide/concepts/interfaces). ## 2. Drive a session over the socket `client/zeropty.mjs` keeps one connection to the server's socket (`~/.zeropty/mux.sock`, or `ZEROPTY_SOCK`) and sends each command over it. If no server is running, it runs the CLI, so the first `new-session` starts one. This is [`examples/node/quickstart.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/quickstart.mjs): ```js [examples/node/quickstart.mjs] import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { zeropty } from '../../client/zeropty.mjs' const sock = path.join(os.tmpdir(), `zeropty-quickstart-${process.pid}.sock`) const zp = (args, opts) => zeropty(args, { sock, ...opts }) // Start a session; with no server on the socket, new-session starts one (as tmux does). // A real host runs an agent here: dist/claude-zeropty, or any program. let r = await zp(['new-session', '-d', '-s', 'job', '-x', '80', '-y', '20', 'sh']) if (!r.ok) throw new Error(r.err) // Give it work. await zp(['send-keys', '-t', '=job:', '-l', 'for i in 1 2 3; do echo step $i; sleep 0.2; done; echo "finished: $?"']) await zp(['send-keys', '-t', '=job:', 'Enter']) // Wait until it is done: the text shows up (the typed line holds `$?`, not `0`) and the output // stays quiet for 300 ms. No polling: the server checks as output comes in. const t0 = performance.now() r = await zp(['wait-pane', '-t', '=job', '-m', 'finished: 0', '-q', '300', '-T', '10000']) console.log(`wait-pane: ${r.ok ? 'done' : r.err.trim()} after ${Math.round(performance.now() - t0)} ms`) // Read the result. const { out } = await zp(['capture-pane', '-p', '-t', '=job:']) console.log(out.trimEnd()) // Which sessions show a given text, across all of them at once. console.log((await zp(['grep-panes', 'finished: 0'])).out.trimEnd()) await zp(['kill-server']) fs.rmSync(sock, { force: true }) ``` The example uses a socket of its own, so it leaves your sessions alone. In your app, drop `sock` to use the default server. - `zeropty(argv, { cwd, sock, timeout })` has a default timeout of 15 seconds. - `wait-pane` opens a separate connection, so other commands keep flowing while it waits. It has no default timeout: set one with `-T` or `{ timeout }`. - A failed command does not throw. Check `r.ok`, or `r.code` and `r.err`. ## 3. Call synchronously when you need to `zeroptySync(argv, { cwd, sock })` sends the same command and blocks the thread until the reply, for 0.13~0.26ms per command. It needs `dist/zeropty.node`: ```js [app.mjs] import { zeroptySync } from './client/zeropty.mjs' const { out } = zeroptySync(['capture-pane', '-p', '-t', '=job:']) ``` `sockPath()` and `cliPath()` return the socket (`ZEROPTY_SOCK`) and CLI (`ZEROPTY_BIN`) the client uses. ## 4. Embed the engine instead To put the engine inside your process, change only the import. This is [`examples/node/embedded.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/embedded.mjs), shortened: ```js [examples/node/embedded.mjs] import { zeropty } from '../../client/embedded.mjs' const r = await zeropty(['new-session', '-d', '-s', 'w', '-x', '60', '-y', '8', 'exec cat']) if (!r.ok) throw new Error(r.err) await zeropty(['send-keys', '-t', '=w:', '-l', 'hello from an embedded engine']) await zeropty(['send-keys', '-t', '=w:', 'Enter']) // cat prints the line back once it reads it. for (let i = 0; i < 100; i++) { const { out } = await zeropty(['capture-pane', '-p', '-t', '=w:']) if (out.split('hello').length === 3) { console.log(out.trimEnd()); break } await new Promise((done) => setTimeout(done, 10)) } // The sessions end with this process (their programs get EOF); kill-server ends them now. await zeropty(['kill-server']) ``` Three things change with the engine embedded: - **No `wait-pane`.** The function call has no loop that could defer the answer. Read the screen in a loop as above, or open a socket in step 5 and send `wait-pane` there. - **Sessions end with your process.** To keep them, `push-sessions` them to a server before exiting. For details, see [Move sessions and upgrade the server](/guide/moving). - **Options go to `open`.** Call `open({ history, sizeDir, polled })` before the first command. By default the engine is **polled**: Node's event loop drives it and it has no thread of its own. Pass `polled: false` to give it a thread, for calls from worker threads. ## 5. Open a socket for the CLI `listen(path)` runs the same server as `zeropty serve` inside your app. Then the CLI, `attach` and other scripts reach your app's sessions: ```js [app.mjs] import { zeropty, listen } from './client/embedded.mjs' listen(`${process.env.HOME}/.zeropty/myapp.sock`) // throws if another server already answers on this socket ``` ```sh [Terminal] ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w ``` The path must be 103 bytes or less. ## Verify Run the socket example: ```sh [Terminal] node examples/node/quickstart.mjs ``` You should see the wait time, the screen and the `grep-panes` match. `NNN` stands for the milliseconds, which vary from run to run: ```text wait-pane: done after NNN ms for i in 1 2 3; do echo step $i; sleep 0.2; done; echo "finished: $?" step 1 step 2 step 3 finished: 0 job: finished: 0 ``` Then run `node examples/node/embedded.mjs`. It prints a `capture-pane: … µs each` timing, then the line twice: once echoed as typed, once printed back by `cat`. ## Troubleshooting | Symptom | Fix | |---|---| | `Cannot find module …/dist/zeropty.node` | Run `./build.sh`, or set `ZEROPTY_NODE` to the module's path. | | A `wait-pane` call never resolves | It has no default timeout. Pass `-T` or `{ timeout }`. | | `wait-pane` fails on the embedded engine | Not available as a function call. Loop on `capture-pane`, or `listen` and use the socket client. | | `listen` throws | Another server answers at that path. Pick another path, or stop that server. | | A plugin command attached to your own `listen` socket times out | Call it with `await zeropty(...)`, not `zeroptySync`, which holds the JS thread the plugin runs on. | ## Next steps - [Socket protocol](/reference/socket-protocol#from-other-programs-the-socket) to talk to the server from any language. - [Node.js API](/reference/node-api) for every Node export and `open` option. - [Embed zeropty in C or Rust](/guide/embed) for the same engine outside Node. --- <!-- https://zeropty.pubdoc.dev/guide/plugins-internals --> # Plugin internals This page is for people who change zeropty itself or write their own host loop. How to write plugins and the rules they follow are in [Plugin API](/reference/plugin-api). ## Structure The plugin layer sits above the engine, next to the socket server. Dependencies only point downward (see the layer table in `src/lib.rs`). ``` embed (Embedded, Polled) ─┐ zeropty serve (src/cli/serve.rs) ─┴→ host::HostLoop ──┬→ Service: server::Server (socket, plugin-attach, plugin command routing) └→ Service: plugin::Plugins (registry: event delivery, commands issued by plugins, watchdog) ↓ engine (engine::tap: queues only the session event kinds someone wants) ``` - **Engine** (`src/engine/tap.rs`): queues session creation, exit, rename and output as events. Event kinds that no plugin wants are skipped with a single mask branch, and nothing is built for them. `Event` and `ExitReason` are plain data defined here, so the engine knows nothing about the layers above it; `zeropty::plugin` re-exports the same types. - **Registry** (`Plugins`): a `Service` of the host loop. It plugs into exactly the same slot as the server (`host::Service`), and `HostLoop` runs it after the server on every turn (`after_tick` = event delivery, running commands that plugins issued, and the watchdog). The event that plugin threads use to wake the loop (a user event on the engine queue, registered by `Plugins::attach`) is filtered out by the service's `owns`, so it never leaks to the host. The sleep time is bounded by `Plugins::watchdog_ms` together with the server's `wait_timeout`. - **Server** (`server::Server::set_plugins`): handles `list-plugins`, routing of plugin commands (only the calling connection waits for the reply), and `plugin-attach` (which hands the connection over to become a plugin). - As a result, all three hosts (`zeropty serve`, `Embedded`, `Polled`) run plugins in the same order. A unit test in `host.rs` (`plugins_are_pumped_after_the_service_and_cap_the_wait`) checks that order with a fake engine. ## Cost with no plugins What extra work is done is described in [PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#cost-with-no-plugins). These are measured values with no plugins loaded. On 2026-10-20, on the same Mac mini (M1), `main` 015f4da (no plugins) and this branch at c7ef81f (the commit where `Polled::timeout_ms` next_due, `command_start` and `process_within` in `src/embed.rs` took their current form) were run alternately, 3 times each. The load average was 2.5\~5.0, so compare the two columns rather than the absolute values. Ranges are min\~max over the 3 runs (for hostloop_bench, the last three rounds of each run, so 9 values). | Measurement | `main` 015f4da | c7ef81f | |---|---|---| | One polled turn, idle session (`process()` + `timeout_ms()`, median) | 12.58\~12.63 us | 12.58\~12.61 us | | One polled turn, session replaying claude output | 12.54\~13.26 us | 12.60\~12.94 us | | `Polled::command` display-message | 446\~481 ns | 446\~470 ns | | `Embedded::command` display-message | 455\~492 ns | 457\~485 ns | | Embedded capture-pane, idle session, p50 | 4.3\~5.0 us | 3.7\~4.4 us | | Embedded capture-pane -e -S -200, idle session, p50 | 4.7\~5.5 us | 4.7\~5.4 us | | Embedded capture-pane, session producing output (`BUSY=1`), p50 | 5.7\~11.7 us | 6.0\~13.5 us | | Embedded send-keys cooked, p50 | 40.0\~42.5 us | 41.0\~45.0 us | | Embedded send-keys raw, p50 | 30.8\~37.9 us | 32.9\~35.5 us | Every difference is within the noise of re-running the same build (the ranges overlap, and on noisy rows such as capture-pane on a busy session the order flips from run to run). To measure again, run the two builds alternately. This table was measured at c7ef81f. The only engine changes in later commits are inside `if stats.tap` in `src/engine/session.rs` and in the `#[cold]` functions of `src/engine/tap.rs` that are called only from there (`copy`, `read_now`: the 64KB chunking and byte budgets that run only when some plugin wants output). So the code that runs with no plugins is the same as at c7ef81f (`git diff c7ef81f..HEAD -- src/engine`). ```sh cargo run --release --example hostloop_bench # one polled turn and the command path (tune with N, ROUNDS; median per round) cargo run --release --example capture_bench # BUSY=1 for a session producing output cargo run --release --example sendkeys_bench ``` To measure on `main`, extract it with `git archive main | tar -x -C <dir>` and copy `examples/hostloop_bench.rs` into it (`main` does not have it). The table above uses the last three rounds of each run (the first two rounds are warm-up). --- <!-- https://zeropty.pubdoc.dev/guide/plugins --> # Write a plugin By the end of this guide, you build a small **plugin** as a C library, attach it to the zeropty server, call the command it adds from the CLI and keep it running with every server. The plugin is `tally`: it counts sessions created and ended, and reports how many are live. ## Before you begin - A Mac with zeropty on your `PATH`. - The zeropty repository, for `bindings/c/include/zeropty.h` and `examples/plugins/tally.c`. Run the commands from that folder. A plugin library needs only the header; it does not link against `libzeropty_c`. - `cc` from the Xcode Command Line Tools. ## 1. Choose how to attach A plugin listens to session events and adds commands. Its commands are called like built-in ones, from the CLI, the socket or an embedded call. There are four ways to attach one, all sharing the same registry, events, access levels and isolation rules: | Method | Language | Where it runs | Example | |---|---|---|---| | Socket (out of process) | Any language (socket and JSON) | A separate process | `examples/plugins/hello.py`, `examples/plugins/notify.mjs` | | Plugin library (`.dylib`) | C, or anything that exports C functions | A separate process via `zeropty plugin-run`, or inside a host | `examples/plugins/tally.c` | | C ABI (application binary interface) callback | C, or anything that passes C functions | Host process, on a plugin thread | `examples/plugins/count.c` | | Rust trait | Rust | Host process, on a plugin thread | `examples/plugin_activity.rs` | This guide builds a library, because the same `.dylib` runs both beside the server and inside an embedding host. Plugin code never runs on the engine thread, so a slow or crashing plugin does not stop your sessions. ## 2. Write the plugin A library has one entry point, `zeropty_plugin_init`. It fills in a manifest (name, events, commands) and a callback. The callback gets one JSON message at a time, on the plugin's own thread: ```c [examples/plugins/tally.c] #include <stdatomic.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include "zeropty.h" static const zeropty_plugin_api *zp; /* from the host: host_command and free */ static atomic_int created, ended; /* One JSON message at a time, on the plugin's own thread (a real plugin would use a JSON parser). */ static char *on_message(void *user, const char *msg, zeropty_host *host) { (void)user; if (strstr(msg, "{\"event\":\"session-created\"") == msg) created++; if (strstr(msg, "{\"event\":\"session-exited\"") == msg) ended++; if (strncmp(msg, "{\"call\"", 7) != 0) return NULL; /* start, events, stop: nothing to answer */ /* Asks zeropty, from inside the plugin, for the sessions running now: one "x" per session. */ char *r = zp->host_command(host, "{\"argv\":[\"list-sessions\",\"-F\",\"x\"],\"cwd\":\"/\"}"); int live = 0; const char *out = r ? strstr(r, "\"out\":\"") : NULL; for (const char *p = out ? out + 7 : ""; *p && *p != '"'; p++) live += *p == 'x'; zp->free(r); char *reply = malloc(128); /* zeropty frees it */ snprintf(reply, 128, "{\"code\":0,\"out\":\"created %d, ended %d, live %d\\n\",\"err\":\"\"}", created, ended, live); return reply; } int zeropty_plugin_init(const zeropty_plugin_api *api, zeropty_plugin_def *def) { if (api->api < 1) return 1; zp = api; def->manifest_json = "{\"name\":\"tally\",\"events\":[\"session-created\",\"session-exited\"],\"commands\":[\"tally\"]}"; def->callback = on_message; def->user = NULL; return 0; } ``` The callback receives `{"start":{"api":1}}` first, then events and calls, and `{"stop":true}` last. For a `call`, return a `malloc`'d reply with `code`, `out` and `err`; zeropty frees it. For anything else, return `NULL`. `host_command` runs a zeropty command from inside the plugin, within the plugin's **access**. The manifest above declares none, so it gets the default, `read`: `list-sessions` and `capture-pane` work, `send-keys` does not. Add `"access":["input"]` to the manifest to send keys. ## 3. Build it Build the library with the header alone: ```sh [Terminal] cc -dynamiclib -O2 -Ibindings/c/include -o /tmp/tally.dylib examples/plugins/tally.c ``` ## 4. Attach it to the server Start a server if none is running, run the library with `plugin-run`, then call its command: ```sh [Terminal] zeropty new-session -d -s a sh # starts the server if none is running zeropty plugin-run /tmp/tally.dylib & zeropty tally ``` `plugin-run` loads the library into a process of its own and attaches it to the server over the socket. If the library crashes, only that process dies. When the server exits, `plugin-run` exits too. ## 5. Start it with every server Put the library in a plugin directory and point `ZEROPTY_PLUGIN_DIR` at it. The server starts the plugins in it and restarts them if they exit: ```sh [Terminal] export ZEROPTY_PLUGIN_DIR=~/.zeropty/plugins # put this in ~/.zshrc to keep it always on mkdir -p ~/.zeropty/plugins cp /tmp/tally.dylib ~/.zeropty/plugins/ # a .dylib is started with zeropty plugin-run zeropty restart-server # takes effect from the next server ``` ::: warning Off by default Without `ZEROPTY_PLUGIN_DIR`, the server runs nothing, even if `~/.zeropty/plugins` exists. Setting it is your consent to run the code in that directory as your user. Directories and files that other users can write to are skipped. ::: - The server takes `ZEROPTY_PLUGIN_DIR` from the shell that starts it: the `new-session` that starts the server, or the `restart-server`. - Executables and `.dylib` files start in name order. Each plugin's output goes to `NAME.log` in the directory. - A plugin that exits quickly is restarted after 0.5 seconds, doubling up to 30 seconds. - Plugins survive `restart-server`: the old server's `plugin-run` exits with it, and the new server starts its own after it takes over the socket. ## 6. Call it from a program Plugin commands are called like any command. From a host that embeds the engine, you can choose whether the calling thread waits: | Host | Waits for the reply | Returns at once | |---|---|---| | C | `zeropty_command` | `zeropty_command_start` → `zeropty_call_poll` → `zeropty_call_free` | | Rust `Embedded`, `Polled` | `command(argv, cwd)` | `command_start(argv, cwd) -> Call` | | Node (`client/embedded.mjs`) | `zeroptySync(argv)` | `await zeropty(argv)` | An embedding host can also load the same library into its own process with `zeropty_load_plugin(a, "/tmp/tally.dylib")`. Then a crash in the library is a crash of that process. ## Verify Start two sessions and end one, then call the command and list the plugins: ```sh [Terminal] zeropty new-session -d -s b sh zeropty new-session -d -s c sh zeropty kill-session -t '=c:' zeropty tally zeropty list-plugins ``` You should see the counts since the plugin started. `live` counts every running session, so with only `a` and `b` left it reads: ```text created 2, ended 1, live 2 ``` `list-plugins` prints one line per plugin, in the form `name: state (api N) events=... commands=... access=... dropped=N`. Look for `tally: running`. ## Troubleshooting | Symptom | Fix | |---|---| | `unknown command` for `tally` | The plugin is not attached. Check `zeropty list-plugins`. Right after `restart-server`, plugins reattach within tens of ms. | | `failed (stuck in a hook ...)` | A callback ran longer than the deadline (10 seconds, `ZEROPTY_PLUGIN_TIMEOUT_MS`). Reply "started" first and let the result be fetched with another command. | | `plugin tally lacks access input` | Add the access to the manifest, for example `"access":["input"]`. | | `bad manifest: ...` | The JSON manifest allows only `name`, `events`, `commands`, `access` and `api`. Check for typos such as `"event"`. | | `dropped=` keeps growing | The plugin reads events too slowly; its queue holds 1024 events or 8MB of output. Prefer `activity` over `output`. | | The plugin directory does nothing | `ZEROPTY_PLUGIN_DIR` was not set in the shell that started the server. Set it and run `zeropty restart-server`. | ## Next steps - [Plugin API](/reference/plugin-api#events) for every event, and [access levels](/reference/plugin-api#access). - [Socket protocol for plugins](/reference/plugin-api#1-from-another-process-the-socket-protocol-any-language) to write a plugin in Python or Node.js. - [Embed zeropty in C or Rust](/guide/embed) to host plugins in your own process. --- <!-- https://zeropty.pubdoc.dev/guide/releasing --> # Releasing How a change goes from a commit, through the CHANGELOG, to a release. What counts as a breaking change is in [Versioning](/guide/versioning), and the commit format is in [Contributing](/guide/contributing). ## At a glance ``` commit (Conventional Commits) ──▶ PR: scripts/check.sh checks the commit format and CHANGELOG [Unreleased] │ ▼ merged into main release PR: node scripts/release.mjs bump <level> ──▶ changes the version everywhere at once and moves [Unreleased] to ## [X.Y.Z] - date │ ▼ after the merge, the maintainer tags vX.Y.Z (release.mjs check --tag compares the tag with the version) tag pushed: .github/workflows/release.yml ──▶ builds and smoke-tests the macOS and Linux archives and attaches them and SHA256SUMS to the GitHub Release (created from the CHANGELOG) ``` - **Commits are the source of the change history.** Every commit uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format (checked by `scripts/check.sh` and `.githooks/commit-msg`), and its type decides the release level. - **CHANGELOG.md is prose for people.** In the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format, each user-visible change (`feat`, `fix`, `perf`, `revert`, a breaking change) gets one line under `## [Unreleased]` in the same PR. `node scripts/release.mjs notes` drafts it from the commits ([git-cliff](https://git-cliff.org), configured in `cliff.toml`). - CHANGELOG entries are written in English, like the documentation, with a Korean copy of each entry in [Changelog](/ko/guide/changelog). The git-cliff draft comes from the commit messages; read it and rewrite it as sentences that make sense to users. Section names (`Added`, `Changed`, ...) and code and identifiers stay as they are in both files. - **People write the sentences; machines check the structure.** For every PR, `scripts/check.sh` (`check-commits.mjs --changelog`) compares the sections of new entries in `[Unreleased]` with the sections of the git-cliff draft (see [Why it is semi-automatic](/guide/releasing#why-it-is-semi-automatic) below). - **The public interface is also checked by machine.** `api/c-abi.txt` (the `zeropty_*` symbols of `libzeropty_c.dylib` and the declarations in `zeropty.h`) and `api/node.txt` (the Node module exports) are the baselines. `scripts/check.sh` compares them with the build (`node scripts/check-api.mjs`), and when a baseline line disappears in a PR it requires a breaking mark (`!` or `BREAKING CHANGE:`) and a `**BREAKING**` entry ([Versioning](/guide/versioning#machine-checks)). - The tools in this repository **never commit, tag, push or publish.** They only change files; people check and do the rest. ## Why it is semi-automatic We do not use the git-cliff output as the CHANGELOG verbatim; a person rewrites the draft for users. This is a deliberate choice. - **The readers differ.** A commit message is a one-line note for people changing the code, and a CHANGELOG entry is documentation for people using zeropty. A list of commit titles leaves out "what you have to change". - **Several commits become one entry.** When one feature is split over several `feat` commits, or a feature not released yet is fixed with a `fix` in the same cycle, the draft has several lines but users need one. - **Instead, `scripts/check.sh` catches entries that are missing or filed in the wrong place.** `check-commits.mjs --changelog` classifies the PR's commits with the same rules as `cliff.toml` (`sectionsFor` in `scripts/lib/conventional.mjs`) and checks that the **new or changed entries** in `[Unreleased]` are in those sections. | Commit | Section in the git-cliff draft | New entry the check requires | |---|---|---| | `feat` | Added | under `### Added` | | `fix` | Fixed | under `### Fixed` | | `fix(security)` | Security | under `### Security` | | `perf`, `revert` | Changed | under `### Changed` | | breaking change (`!`, `BREAKING CHANGE:`) | Changed | `- **BREAKING** ...` under `### Changed` or `### Removed` | | `refactor`, `docs`, `test`, `build`, `ci`, `chore`, `style` | (left out) | none | | any type + a `Changelog: skip` footer (except breaking changes) | (left out) | none | - The only headings allowed in `[Unreleased]` are `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed` and `Security`, in this order, each at most once. Every entry must be under a `###` heading. This is always checked, even with `ZEROPTY_SKIP_CHANGELOG=1`. - The wording is not compared. The entries are rewritten rather than copied from the draft, so as long as the section matches, the check passes. - If the two classification rules (`cliff.toml` and `sectionsFor`) disagree, `scripts/test/cliff.test.mjs` fails. It renders commits with the real git-cliff and compares the section of each line with `sectionsFor`. - A commit that is not news to users, such as a `fix` for a feature not released yet (one still under `[Unreleased]`), gets a `Changelog: skip` footer at the end of its commit message. Only that commit is left out of the check and the git-cliff draft; the other commits in the same PR are still checked. If needed, reword the existing entry. It cannot be used for breaking changes (the commit check rejects it). - If the whole PR is invisible to users but has `feat`/`fix` types, say so in the PR description, and the maintainer skips the entry check with `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh`. The commit format is still checked. ## Where the version lives `release.mjs bump` changes all of the following at once, and `tests/version.rs`, the `bindings/c` unit tests and `release.mjs check` verify that they agree. | File | Contents | |---|---| | `Cargo.toml` | `[workspace.package] version`. The three crates inherit it with `version.workspace = true` | | `Cargo.lock` | The `zeropty`, `zeropty-c` and `zeropty-node` entries | | `bindings/c/include/zeropty.h` | `ZEROPTY_VERSION_MAJOR`, `MINOR`, `PATCH`, `PRERELEASE`, `IS_PRERELEASE`, `STRING` (`NUMBER` is computed) | | `client/zeropty.mjs`, `client/embedded.mjs` | `export const VERSION` | | `CHANGELOG.md` | `## [X.Y.Z] - YYYY-MM-DD` and the comparison links at the bottom | `zeropty --version`, the server's `#{version}`, `zeropty_version()` and Node's `version()` come from the Cargo version at build time, so there is nothing else to change. `bump` does not touch the Korean copy [Changelog](/ko/guide/changelog); when you polish the release section (step 4 below), apply the same heading and entries there by hand. The links at the bottom of the CHANGELOG point **only to tags that exist**. If the previous version has a tag, they use `compare/vA...vB`; for a version whose predecessor has no tag, they start from the commit the `[Unreleased]` link starts from. A link to a tag that does not exist makes `node --test scripts/test` fail, except the tag of the version being released (the release PR links it; the tag comes after the merge). ## Commands ```sh node scripts/release.mjs check # whether the version agrees everywhere (also run by scripts/check.sh) node scripts/release.mjs check --tag v0.2.0 # and whether the tag matches that version (also run by scripts/check.sh on a tagged HEAD) node scripts/release.mjs next # the next version, computed from the commits since the last tag node scripts/release.mjs notes # the [Unreleased] draft (git-cliff, fetched with npx if missing) node scripts/release.mjs release-notes 0.2.0 # the GitHub Release body, from CHANGELOG.md's ## [0.2.0] node scripts/release.mjs bump minor --dry-run node scripts/release.mjs bump <major|minor|patch|auto|X.Y.Z[-pre]> [--date YYYY-MM-DD] [--generate] node scripts/check-commits.mjs --range origin/main..HEAD --changelog # only the commit and changelog check of scripts/check.sh (including the section comparison) scripts/smoke-version.sh # whether the CLI, server, C and Node versions in dist/ agree (the version smoke tests step of scripts/check.sh) scripts/package.sh # the release archive and SHA256SUMS in dist/release/, smoke-tested unpacked (needs rustup target add aarch64-apple-darwin x86_64-apple-darwin) rustup run 1.88.0 cargo check --workspace --all-targets --locked # MSRV (the MSRV step of scripts/check.sh) node scripts/check-api.mjs # whether the C ABI and Node exports in dist/ (./build.sh) match api/*.txt node scripts/check-api.mjs --base origin/main # whether lines removed from the baseline are marked as breaking (PR check) node scripts/check-api.mjs --update # update the baseline when you changed the interface on purpose (in the same commit) node --test scripts/test/*.test.mjs # tests for the tools themselves (including git-cliff rendering) ``` - `bump auto` uses the level `next` computes. Before 1.0, that is MINOR if there is a breaking change and PATCH otherwise. - `bump` first checks that `check` passes and that the working tree is clean; if either fails, it changes no files. - It stops if `[Unreleased]` is empty. With `--generate`, it fills it from the git-cliff draft (read and rewrite it anyway). - After changing the version, check that `Cargo.lock` is consistent with `cargo metadata --locked`. ## Release procedure 1. **Check that `scripts/check.sh` passes on main.** Every step must pass without `--quick` (including MSRV, the examples and rustdoc). 2. **Decide the level.** ```sh git switch main && git pull node scripts/release.mjs next ``` Compare the result with the rules in [Versioning](/guide/versioning). If you raised the MSRV, or a `build` or `refactor` commit made a user-visible difference, the computation does not see it, so choose the level yourself. 3. **Bump the version on a release branch.** ```sh git switch -c release/0.2.0 node scripts/release.mjs bump minor # or auto, 0.2.0, 0.2.0-rc.1 ``` 4. **Polish the CHANGELOG.** Read the `## [0.2.0]` section from a user's point of view. - Merge lines about the same thing into one, and leave out internal details. - For a breaking change, write what users have to change, along with `**BREAKING**`. - If you mention performance numbers, use only values measured in [Benchmarks](/guide/benchmarks). - Make the same changes in the Korean copy, [Changelog](/ko/guide/changelog). 5. **Verify.** ```sh cargo build --release --workspace && cargo test --release --workspace && ./build.sh dist/zeropty --version # zeropty 0.2.0 node scripts/release.mjs check ``` 6. **Commit and open a PR.** The commit message is `chore(release): 0.2.0` (git-cliff leaves this commit out of the CHANGELOG). Use the same PR title. 7. **After the merge, the maintainer creates the tag.** ```sh git switch main && git pull git tag -a v0.2.0 -m "zeropty 0.2.0" git push origin v0.2.0 ``` Before pushing, check that the tag matches the version with `node scripts/release.mjs check --tag v0.2.0` (on a tagged HEAD, `scripts/check.sh` runs this check too). 8. **The tag publishes the release.** `.github/workflows/release.yml` (a macOS runner, and a Linux one for the Linux archive) checks the tag against the version, runs `scripts/package.sh` and `scripts/package-linux.sh`, and attaches their files to the release: - `zeropty-0.2.0-darwin-universal.tar.gz`: one folder, `zeropty-0.2.0-darwin-universal/`, holding `zeropty` (arm64 + x86_64, `lipo`), `shim.dylib` (arm64, arm64e, x86_64), `zeropty-tty`, `claude-zeropty`, `LICENSE-APACHE` and `LICENSE-MIT`, with their execute bits. `zeropty` and `shim.dylib` are signed ad hoc (`codesign -s -`); Developer ID signing and notarization may come later. - `zeropty-0.2.0-linux-x86_64.tar.gz`: one folder of that name holding `zeropty`, `shim.so` (for `LD_PRELOAD`), `zeropty-tty`, `claude-zeropty` and the licenses. Built by the `package-linux` job with `scripts/package-linux.sh` on Debian 11 (`rust:1.88-bullseye`), so it needs glibc 2.29 or later; the script prints each file's newest glibc symbol and smoke-tests the unpacked archive. - `SHA256SUMS`: the SHA-256 of every archive, made by the publish job once both are built. Whoever pins a version pins this hash. If the tag has no release yet, the workflow creates it with `node scripts/release.mjs release-notes 0.2.0` as the body (the CHANGELOG section, one line per entry, relative links made absolute at the tag); a tag with a pre-release part (`v0.2.0-rc.1`) becomes a prerelease, never "Latest". A release you created before the workflow got there (about a minute after the push) keeps its body. Check the run under Actions, then download the files and run `shasum -a 256 -c SHA256SUMS`. Both binaries load on macOS 11 and later (`package.sh` checks each slice's minimum). - The workflow never replaces files already on a release: a rebuild is not byte-identical, and a new hash would break whoever pinned the old one. To replace them on purpose, delete them from the release, then re-run. - A re-run builds the tagged commit again, so it helps only with a failure outside the repository (a runner or network problem). A fix to the packaging needs a new commit and the next PATCH release. - The embedding libraries and the bash builtin are not in the archive; they come from `./build.sh`. > 0.1.0 is the first public commit (`c612089`), tagged `v0.1.0`; 0.1.1 was the first release made with these tools. `next` and `notes` compute from the latest tag. ## Formatting The whole workspace is rustfmt-formatted (configured in `rustfmt.toml`). Once the branches in progress had all landed on main, it was formatted in one go with a single `style: cargo fmt --all` commit (`e12f84f`). - The rustfmt step of `scripts/check.sh` checks the whole tree with `cargo fmt --all --check`. Run `cargo fmt --all` before committing. - For an old branch that forked before the formatting commit, pick your side in the conflicting files and run `cargo fmt --all` again. Formatting is mechanical, so the result is the same. - To skip the formatting commit in `git blame`, use `git blame --ignore-rev e12f84f <file>`. ## Prereleases and patch releases - **Prerelease:** `node scripts/release.mjs bump 0.2.0-rc.1`. The tag is `v0.2.0-rc.1`. The final release is `bump patch` (`0.2.0-rc.1` → `0.2.0`) or `bump 0.2.0`. The header's `ZEROPTY_VERSION_PATCH` and `ZEROPTY_VERSION_NUMBER` hold only numbers, so `0.2.0-rc.1` and `0.2.0` are the same there. Tell them apart with `ZEROPTY_VERSION_PRERELEASE` (`"rc.1"`, `""` for a final release), `ZEROPTY_VERSION_IS_PRERELEASE` (1/0, usable in `#if`) and `ZEROPTY_VERSION_STRING` (`"0.2.0-rc.1"`). - **Fixing an earlier version (backport):** create a branch from the tag (`git switch -c release/0.1.x v0.1.0`). Cherry-pick the fix commits, record them under `[Unreleased]`, and follow the same procedure with `bump patch`. Copy that version's section into the CHANGELOG on main as well. ## Troubleshooting | Symptom | Fix | |---|---| | `tests/version.rs` fails | A version was edited by hand. See where they disagree with `node scripts/release.mjs check`, and bring them back in line with `bump` | | The commit messages step of `scripts/check.sh`: commit format | Fix the message with `git rebase -i` (`reword`). Fold fixup commits in with `--autosquash` | | The commit messages step of `scripts/check.sh`: changelog | Add a line under `## [Unreleased]`. For a change users do not see, skip the entry check with `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh` | | `needs an entry under ### Fixed for: ...` | The entry is in another section. Move it to the section that matches the commit type (the table in [Why it is semi-automatic](/guide/releasing#why-it-is-semi-automatic)). `node scripts/release.mjs notes` shows git-cliff's classification | | `[Unreleased]: "### Fixes" is not one of ...` | Use only the six Keep a Changelog headings, in the order `Added` → `Security` | | Fails because a breaking change has no `**BREAKING**` line | Add a `- **BREAKING** ...` line under `[Unreleased]` | | `check-api.mjs`: `api/*.txt does not match the build` | If you changed the interface on purpose, run `node scripts/check-api.mjs --update` and commit `api/` with it. If not, revert the changed declarations or exports | | `check-api.mjs --base`: `public API removed or changed` | A line disappeared from the baseline (a function removed, a signature changed). If intended, add `!` or `BREAKING CHANGE:` to the commit and `- **BREAKING** ...` under `[Unreleased]`; if not, add it under a new name instead | | The rustfmt step of `scripts/check.sh` fails | Format with `cargo fmt --all` and commit ([Formatting](/guide/releasing#formatting)) | | The MSRV step of `scripts/check.sh`: `Rust 1.88.0 is not installed` | `rustup toolchain install 1.88.0` | | `bump` stops with `nothing under ## [Unreleased]` | Write the entries, or pass `--generate` | | A tag was created by mistake | Before pushing, `git tag -d vX.Y.Z`. After pushing, do not delete it; release the next PATCH instead | --- <!-- https://zeropty.pubdoc.dev/guide/shell --> # Drive agents from the shell By the end of this guide, you drive several sessions from a shell script: you start them, type into them, wait until each one is done and find the ones that match. You also switch the script to the fast path, which talks to the socket without starting a process for every command. ## Before you begin - A Mac with zeropty on your `PATH`. For details, see [Install and platform support](/guide/install). - For the bash builtin, a build from source (`./build.sh` puts `dist/zeropty_bash.dylib` in place). The release archive does not carry it. - The steps run `sh` in each session, so you do not need claude. A real script runs an agent instead, for example `dist/claude-zeropty` (see [Run claude and other terminal programs](/guide/terminal-programs)). ## 1. Start a session A **session** is one program kept running by the **server**. The first `new-session` starts the server if none is running: ```sh [Terminal] zeropty new-session -d -s build -x 80 -y 6 sh ``` `-d` leaves your terminal free, `-s build` names the session, and `-x`/`-y` set the screen size. For details, see [Sessions and the server](/guide/concepts/sessions). ## 2. Send keys Every command names its session with a **target** (`-t`). In scripts, write it as `'=build:'`, quoted: ```sh [Terminal] zeropty send-keys -t '=build:' -l 'ls / | head -3; echo "exit: $?"' zeropty send-keys -t '=build:' Enter ``` Each argument to `send-keys` is either a key name or literal text: | You write | What is sent | |---|---| | `Enter`, `C-c`, `Up`, `F5` | The key's bytes ([key names](/reference/keys-and-formats#key-names)) | | `-l 'Enter'` | The five letters `Enter`, never a key | | `-H 41 42 0a` | Raw bytes in hexadecimal (`AB` and a newline) | | `-N 3 Down` | The whole argument list three times | Use `-l` for any text that comes from a prompt or a variable, so a word such as `Enter` or `Space` in it is never read as a key. Send `Enter` as a separate command without `-l`. ::: warning Quote the target in zsh zsh turns `=word` into the path of a command (`=` expansion). Write `'=build:'`, not `=build:`. tmux targets have the same problem. ::: ## 3. Wait for the result `wait-pane` answers when text appears (`-m`), when output pauses (`-q`), or when both hold. `-T` is the timeout: ```sh [Terminal] zeropty wait-pane -t '=build:' -m 'exit: 0' -T 10000 ``` The server checks only when output arrives, so nothing polls. Typed keys are echoed to the screen, so wait for text that only the result prints. The typed line above holds `$?`, not `0`, so `exit: 0` matches only the output. For an agent whose final text you cannot predict, wait for quiet instead: ```sh [Terminal] zeropty wait-pane -t '=fix-1:' -q 5000 -T 600000 # until output pauses for 5 s, at most 10 minutes ``` `wait-pane` exits with code 1 on timeout (`wait-pane: timed out`) or when the session ends. For details, see [Screens, keys and waiting](/guide/concepts/screens). ## 4. Read the screen `capture-pane -p` prints the screen. Pending output is processed first, so the screen is always current: ```sh [Terminal] zeropty capture-pane -p -t '=build:' # the visible screen zeropty capture-pane -p -e -S -200 -t '=build:' # plus 200 lines of scrollback, with colors zeropty capture-pane -p -J -S - -E - -t '=build:' # everything, wrapped lines joined ``` | Option | Effect | |---|---| | `-p` | Print to standard output (without it, the screen goes into a buffer) | | `-e` | Keep color and font escapes | | `-J` | Join lines that were split by wrapping | | `-N` | Keep trailing spaces | | `-S`, `-E` | First and last line: 0 is the first screen line, negative numbers are scrollback, `-` means to the end | For every option, see [Reading the screen](/reference/commands#reading-the-screen). ## 5. Drive many sessions Start one session per job, then wait for each and search them all at once with `grep-panes`. Save this as `jobs.sh`: ```sh [jobs.sh] #!/bin/sh set -e for n in 1 2 3; do zeropty new-session -d -s "job-$n" -x 80 -y 10 sh zeropty send-keys -t "=job-$n:" -l "sleep $n; echo \"finished: \$?\"" zeropty send-keys -t "=job-$n:" Enter done for n in 1 2 3; do zeropty wait-pane -t "=job-$n:" -m 'finished: 0' -T 30000 done zeropty grep-panes 'finished: 0' ``` `grep-panes` prints matches as `session: line`, sorted by session name, and exits with 1 when nothing matches. Add `-S -100` to search scrollback too. Across 1000 sessions it took 16~32ms. Use it to find which agents are waiting for input without reading each screen. ## 6. Use the fast path Running the `zeropty` program starts a process for every command (3.59~4.23ms). The zsh function and the bash builtin send the command over the socket from inside the shell instead: ::: code-group ```sh [zsh (~/.zshrc)] source /path/to/zeropty/shell/zeropty.zsh ``` ```sh [bash] enable -f /path/to/zeropty/dist/zeropty_bash.dylib zeropty ``` ::: Replace `/path/to/zeropty` with the folder you cloned. Your script stays the same; `zeropty` is now a function or a builtin. | Path | Measured | |---|---| | zsh function | `send-keys -l` p50 0.12ms (50.9~52.7x faster than the tmux CLI); `has-session` 0.11~0.17ms; a 2000-line `capture-pane` 0.50~0.55ms | | bash builtin | 0.041~0.138ms per command (48.7~157x faster than the tmux CLI), checked with macOS's `/bin/bash` 3.2 | Both still run the program for `attach`, `serve`, `restart-server`, `-V`, `--help`, a call with no arguments, and any command while no server is running. Set `ZEROPTY_BIN` to point them at another program. ## Verify Run the script, then read one screen: ```sh [Terminal] sh jobs.sh zeropty capture-pane -p -t '=job-2:' ``` You should see each session listed once by `grep-panes`: ```text job-1: finished: 0 job-2: finished: 0 job-3: finished: 0 ``` The screen of `job-2` shows the typed line, then `finished: 0`: ```text sleep 2; echo "finished: $?" finished: 0 ``` Clean up with `zeropty kill-server`. ## Troubleshooting | Symptom | Fix | |---|---| | `wait-pane -m` returns at once | The text is in the line you typed. Wait for text that only the result prints. | | `wait-pane: session ended` although the text appeared | The program exited right after printing. Keep it alive, for example `sh -c 'make; echo build done; exec cat'`. | | `can't find session: NAME` or `can't find pane: NAME` for a session you can see | Targets match exactly; there is no prefix matching. Check `zeropty ls`. | | `no server running on ...` | No server listens on that socket. Start one with `new-session`, or set `ZEROPTY_SOCK`. | | `send-keys: unknown flag -X` | Unknown options are errors. See [Sending keys](/reference/commands#sending-keys). | | Input stops after `C-d` | `C-d` on an empty line shuts the session's input for good. Send `C-c` or the program's quit command instead. | ## Next steps - [Run claude and other terminal programs](/guide/terminal-programs) to put agents in these sessions. - [Use zeropty from Node.js](/guide/node) for the same flow without a process per command. - [Commands](/reference/commands#waiting-and-searching) for every `wait-pane` and `grep-panes` option, and [Exit codes](/reference/keys-and-formats#exit-codes). --- <!-- https://zeropty.pubdoc.dev/guide/terminal-programs --> # Run claude and other terminal programs By the end of this guide, you run programs that need a terminal in zeropty sessions: a python REPL, [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) and an interactive shell whose jobs you can stop with `C-c`. ## Before you begin - zeropty installed on a Mac or on Linux. You need `zeropty-tty`, `claude-zeropty` and the shim (`shim.dylib`, or `shim.so` on Linux) in one folder: the release archive and `./build.sh` (in `dist/`) both put them there. `cargo install` does not. For details, see [Install and platform support](/guide/install). - zeropty 0.1.5 or later for `C-c` to reach a job in a shell (step 4). Check with `zeropty -V`. - For step 3, `claude` on your `PATH`. The commands below write `dist/zeropty-tty`. If you use the release folder, write the path of that folder instead. ## 1. See why a program needs a terminal A session's terminal is a socket, not a PTY (pseudo-terminal). Programs that check `isatty()` decide they are not on a terminal: vim, less, the python REPL and claude all behave differently. For details, see [How a session gets a terminal](/guide/concepts/terminal). **`zeropty-tty`** fixes this. It starts the program with a **shim** (`shim.dylib`, injected with `DYLD_INSERT_LIBRARIES` on macOS; `shim.so`, with `LD_PRELOAD` on Linux) that answers these calls as a terminal would: | Call | What the program gets | |---|---| | `isatty`, `tcgetattr` | A terminal, on the session's own socket | | `TIOCGWINSZ` | The session's size | | `tcgetpgrp`, `tcsetpgrp` | The foreground process group | | `/dev/tty` | The session's socket, for reading and writing | | `readpassphrase`, `getpass` | A prompt that does not echo (since 0.1.4) | Only the session's socket counts. A program whose stdout is piped (`zeropty-tty prog | cat`) or whose stdin is `/dev/null` sees no terminal there, as under tmux. ## 2. Start a program through zeropty-tty Put `zeropty-tty` in front of the command: ```sh [Terminal] zeropty new-session -d -s py -x 80 -y 10 "dist/zeropty-tty python3" zeropty send-keys -t '=py:' -l 'print(6 * 7)' zeropty send-keys -t '=py:' Enter zeropty wait-pane -t '=py:' -m '42' -T 10000 ``` vim works the same way: `zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt"`. When the program switches to raw mode, the shim reports it, and keys pass through unchanged. macOS does not let the shim into SIP-protected binaries (`/usr/bin`, `/bin`, `/sbin`, `/System`) or binaries signed with the hardened runtime. `zeropty-tty` runs those from a copy with only the signature changed, kept under `~/.zeropty/tty/` (`ZEROPTY_CACHE`). It rebuilds the copy when the original changes, and runs it under the name you gave, so `ps` and `pgrep` find it. ## 3. Start claude with claude-zeropty **`claude-zeropty`** is a small wrapper that runs `zeropty-tty claude`: ```sh [Terminal] zeropty new-session -d -s fix-1 -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" ``` Replace `~/proj` with the repository the agent works in. To run a different claude, give its path in `ZEROPTY_CLAUDE_REAL`. Set it before the server starts, since sessions get the server's environment, or pass it with `-e`: ```sh [Terminal] zeropty new-session -d -s fix-2 -e ZEROPTY_CLAUDE_REAL=/opt/claude/bin/claude "$PWD/dist/claude-zeropty" ``` Replace `/opt/claude/bin/claude` with the path of your claude. Give the agent work and wait as in [Drive agents from the shell](/guide/shell). ## 4. Run an interactive shell and stop its jobs To type commands at a shell in a session, start the shell through `zeropty-tty`: ```sh [Terminal] zeropty new-session -d -s sh1 "dist/zeropty-tty zsh" zeropty send-keys -t '=sh1:' -l 'sleep 30' zeropty send-keys -t '=sh1:' Enter zeropty send-keys -t '=sh1:' C-c ``` `C-c`, `C-\` and `C-z` go to the foreground job as signals, as a terminal sends them. The shell's `tcsetpgrp` reaches zeropty through the shim, so the shell itself must run with it. The job may be any program, `/usr/bin` included. `C-z` and `fg` work too, pipelines included. Only a process group with a member under the session's program is taken. ::: warning Programs typed at the shell The shim does not get into `/usr/bin` programs typed at a shell in the session. They run without it. Signals still reach them, but `ssh`, `ssh-keygen` and `ssh-add` typed there still echo a passphrase onto the screen and the scrollback. Start them as their own session with `zeropty-tty ssh-keygen` instead. ::: ## Verify Read the python session: ```sh [Terminal] zeropty capture-pane -p -t '=py:' ``` You should see the REPL's banner, then the prompt, your line and the answer: ```text >>> print(6 * 7) 42 >>> ``` The `>>>` prompt and line editing are the REPL's terminal mode, which it turns on only when it sees a terminal. For the shell from step 4, `zeropty capture-pane -p -t '=sh1:'` shows a new prompt after `sleep 30` instead of a session still sleeping. ## Troubleshooting | Symptom | Fix | |---|---| | A program behaves differently because it is "not a terminal" | Start it through `zeropty-tty`. For claude, use `claude-zeropty`. | | A script started through `/usr/bin/env` loses the shim | SIP strips it there. Start the interpreter directly: `zeropty-tty python3 script.py`. | | `top` or `ps` does not run | The copy has none of the original's privileges (setuid, entitlements). Programs that need them do not work. | | `C-c` ends the shell's prompt but not `sleep 30` | The shell was started before the upgrade to 0.1.5 and keeps the old shim. Start the shell again. | | A passphrase shows on screen | The program was typed at a shell and runs without the shim. Start it with `zeropty-tty`. | | `zeropty-tty` cannot find the shim | Keep the folder together, or point `ZEROPTY_SHIM` at the shim (`shim.dylib` or `shim.so`). | ## Next steps - [How a session gets a terminal](/guide/concepts/terminal) for the shim and the line discipline. - [Drive agents from the shell](/guide/shell) to give the agent work and wait for it. - [Environment variables](/reference/environment) for `ZEROPTY_SHIM`, `ZEROPTY_CACHE` and `ZEROPTY_CLAUDE_REAL`. --- <!-- https://zeropty.pubdoc.dev/guide/versioning --> # Versioning zeropty follows [Semantic Versioning 2.0.0](https://semver.org/). This document defines **what the public interface is, and which changes break compatibility**. The change history is in [Changelog](/guide/changelog), and the release procedure in [Releasing](/guide/releasing). ## One version - The whole repository uses **one version**. The `zeropty`, `zeropty-c` and `zeropty-node` crates inherit `[workspace.package] version` from `Cargo.toml`, and the C header's `ZEROPTY_VERSION_*`, the Node clients' `VERSION` and `CHANGELOG.md` carry the same value. - The version is never edited by hand; it changes only through `node scripts/release.mjs bump ...`. If the places disagree, `cargo test` (`tests/version.rs`) and `scripts/check.sh` fail. - Tags are `v` + the version (`v0.1.0`). Prereleases are written like `0.2.0-rc.1`. How to check the version at run time: | Where | How | Result | |---|---|---| | CLI | `zeropty --version` or `zeropty -V` | `zeropty 0.1.0` (the CLI program itself) | | Running server | `zeropty display-message -p '#{version}'` (the same format variable as tmux. Only a format that contains `#{version}` and no session variables answers without a session; with no `-t` and no session, any other format still exits with code 1, as before) | `0.1.0` | | C ABI | `zeropty_version()` (run time), `ZEROPTY_VERSION_STRING`, `_MAJOR`, `_MINOR`, `_PATCH`, `_PRERELEASE`, `_IS_PRERELEASE`, `_NUMBER` (compile time) | `"0.1.0"`. `ZEROPTY_VERSION_NUMBER` is `MAJOR*1000000 + MINOR*1000 + PATCH`, so it **does not include the prerelease**: `0.2.0-rc.1` and `0.2.0` have the same value. Tell them apart with `ZEROPTY_VERSION_PRERELEASE` (`"rc.1"`, `""` for a final release) or `#if ZEROPTY_VERSION_IS_PRERELEASE` | | Node (embedded) | `version()` (`zeropty.node`, `client/embedded.mjs`) | `'0.1.0'` | | Node (client modules) | `VERSION` (`client/zeropty.mjs`, `client/embedded.mjs`) | `'0.1.0'` | The header and the library must come from the same build. A library with a different MAJOR (a different MINOR before 1.0) may not match the header's declarations, so an embedding program does well to compare `zeropty_version()` with `ZEROPTY_VERSION_STRING` at startup. ## Rules for bumping the version | Change | Before 1.0 (now, `0.MINOR.PATCH`) | After 1.0 | |---|---|---| | A change that breaks compatibility of the public interface below | **MINOR** (`0.1.x` → `0.2.0`) | **MAJOR** | | New features (new commands, flags, functions, environment variables), backward compatible | PATCH | MINOR | | Bug fixes, performance improvements, documentation | PATCH | PATCH | | Raising the MSRV, changing the supported macOS versions | MINOR | MINOR | - Before 1.0 we use the same interpretation as Cargo: `0.1.x` versions are compatible with each other, and `0.2.0` may break them. - The level to bump is computed from the commit messages (`node scripts/release.mjs next`). A `!` or a `BREAKING CHANGE:` footer is a breaking change, `feat` is a new feature, and `fix`, `perf` and `revert` are fixes. The detailed format is in [Contributing](/guide/contributing). - A breaking change gets a CHANGELOG line that starts with `**BREAKING**` and says what users have to change. `scripts/check.sh` checks this. ## Public interface (what stays compatible) Changing or removing anything below is a **breaking change**. Adding something new (a new command, flag, function, JSON field or environment variable) is not. ### 1. CLI - Command names and aliases (`new-session`/`new`, `attach-session`/`attach`/`a`, ...), flags and their meaning, and tmux-compatible argument parsing (including the `-t =name:` target format). - Exit codes: 0 on success, 1 on failure (`has-session` finds nothing, `wait-pane` times out, `grep-panes` finds nothing, an unknown option, ...), and 2 when run without arguments. - Output formats that programs read: the default output of `list-sessions` and `-F`, the format variables of `display-message -p`, `new-session -P`, the `session: line` output of `grep-panes`, and `zeropty X.Y.Z` from `zeropty --version`. - The bytes of `capture-pane` output. The same screen must give the same bytes. Making output that differed from tmux match it is a bug fix (`fix`); changing the format on purpose is a breaking change. - The default socket path (`~/.zeropty/mux.sock`) and the behavior of the first `new-session` starting the server. - Error messages meant for people (`err`) are not part of the public interface. Decide by the exit code. ### 2. Socket protocol - A request is one line: `{"argv": [...], "cwd": "...", "raw": bool}`. A reply is one line: `{"code", "out", "err"}`. Several requests per connection, with replies in the order the requests were sent. - The `"raw": true` reply format (`<code> <out byte count> <err byte count>\n` + bytes). - The `attach` frame format and kind values (`FRAME_OUTPUT`, `FRAME_ENDED`, `FRAME_INPUT`, `FRAME_RESIZE`, `FRAME_DETACH`). - New request fields may be added. When an old client does not send them, everything must work exactly as it does now. ### 3. Session handover record - The session record exchanged by `pull-sessions`, `push-sessions` and `restart-server` (`RECORD_VERSION`, currently 1). **`restart-server` is the upgrade path**, so if a newer server cannot read an older record, you cannot upgrade without losing running sessions. - That is why raising `RECORD_VERSION` is a breaking change. If it has to go up, keep reading the older record too, and note in the CHANGELOG from which version to which version `restart-server` works. ### 4. C ABI (`bindings/c/include/zeropty.h`, `libzeropty_c.dylib`) - Function names, argument and return types, meaning, and threading rules (`zeropty_open` from any thread; polled use from one loop thread). - Memory ownership: the result of `zeropty_command` is freed with `zeropty_free`; the string from `zeropty_version()` is not freed. - The keys of the options JSON (`history`, `size_dir`) and the request and reply JSON (the same as the socket protocol). - `zeropty` is an opaque type. Its size and internal layout are not part of the public interface. - The names and meaning of the header's `ZEROPTY_VERSION_*` macros. - Adding functions is not a breaking change. Instead of changing the signature of an existing function, add one under a new name. ### 5. Node API - The exports of `zeropty.node`: `Engine` (constructor options `history`, `sizeDir`, `polled`, `command`, `listen`), `Client` (`command`, `close`), `version()`. - `client/zeropty.mjs`: `zeropty`, `zeroptySync`, `sockPath`, `cliPath`, `VERSION`, the result shape `{ ok, code, out, err }`, and the options (`cwd`, `sock`, `timeout`). - `client/embedded.mjs`: `zeropty`, `zeroptySync`, `open`, `listen`, `version`, `VERSION`. The two modules must be interchangeable by changing only the import. - Supported Node: N-API 6 or later (`napi6`). ### 6. Environment variables | Public (name, meaning and default are kept) | Internal (may change without notice) | |---|---| | `ZEROPTY_SOCK`, `ZEROPTY_HISTORY`, `ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`, `ZEROPTY_COLD`, `ZEROPTY_COLD_DIR`, `ZEROPTY_RECORD` ([Environment variables](/reference/environment#environment-variables)) | `ZEROPTY_COLS`, `ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE`, `ZEROPTY_TTY_ID` (values the engine passes to the shim, used only within the same build) | | `ZEROPTY_BIN`, `ZEROPTY_NODE` (the binary and module the clients use), `ZEROPTY_SHIM`, `ZEROPTY_CACHE`, `ZEROPTY_CLAUDE_REAL` (`zeropty-tty`, `claude-zeropty`) | `ZEROPTY_MALLOC_ADDED` (for re-executing the server), `ZEROPTY_PURGE_MS` (for measurements) | | `ZEROPTY_SESSION` (the session name that programs inside a session read) | Every other `ZEROPTY_*` used only by tests and benchmarks | Changing a default is also a breaking change if behavior changes. The exception is defaults for performance tuning (`ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`): if what you read from the screen stays the same, the change counts as a fix. It still goes under Changed in the CHANGELOG. ### 7. Build outputs and shell integration - The file names `./build.sh` produces in `dist/`: `zeropty`, `libzeropty_c.dylib`, `zeropty.h`, `zeropty.node`, `shim.dylib`, `zeropty_bash.dylib`, `claude-zeropty`, `zeropty-tty`. - The names and behavior of the zsh function (`shell/zeropty.zsh`) and the bash builtin (`enable -f ... zeropty`) (they must behave like the CLI). ## Machine checks The rules are not left to human eyes alone. | What | How | Where | |---|---|---| | C ABI (section 4) | Compares the `zeropty_*` symbols from `nm -gU dist/libzeropty_c.dylib` and the declarations in `zeropty.h` (without parameter names, comments and line breaks) and the macro names with `api/c-abi.txt` | `node scripts/check-api.mjs` (the public API step of `scripts/check.sh`) | | Node API (section 5) | Compares the export names and kinds of `zeropty.node`, `client/zeropty.mjs` and `client/embedded.mjs`, and the methods of their classes, with `api/node.txt` | Same | | Breaking-change marks | If a line in `api/*.txt` disappears or changes in a PR, it passes only when a commit (or the PR title) has `!` or `BREAKING CHANGE:` and `[Unreleased]` has a new `- **BREAKING**` line | `node scripts/check-api.mjs --base <base>` (the public API step of `scripts/check.sh`) | | Version agreement | The crates, `Cargo.lock`, the header macros, Node `VERSION`, `--version`, `#{version}`, the CHANGELOG | `tests/version.rs`, `node scripts/release.mjs check` | | MSRV | `cargo check --workspace --all-targets --locked` with Rust 1.88.0 | The MSRV step of `scripts/check.sh` | | CLI, socket protocol, record | `tests/server.rs`, `tests/handover.rs` and `tests/embed.rs` check the behavior with a real server and clients | `cargo test` | A new function or export only adds a line to the baseline, so it is not a breaking change; but `scripts/check.sh` fails when the build and the baseline differ, so put the `api/` updated with `node scripts/check-api.mjs --update` in the same commit. The Rust library API is not part of the public interface (see below), so we do not use `cargo-semver-checks`. If we decide to make it public, we will add it to `scripts/check.sh` then. ## What is not public interface - **The Rust library API** (`zeropty::engine`, `zeropty::term`, `zeropty::server`, ...). It is not published on crates.io, and the bindings and the server change together in the same repository. Whether to make it public will be decided separately before 1.0. - Performance numbers. Getting slower is treated as a regression, not a compatibility issue (the 10x goal in [AGENTS.md](https://github.com/CreatiCoding/zeropty/blob/main/AGENTS.md), [Benchmarks](/guide/benchmarks)). - The contents of `bench/`, `examples/`, `tests/` and `scripts/`, and the output format of the diagnostic commands (`stats`, `mstats`, `mem`). - Error messages, logs, and the internal format of record files (`ZEROPTY_RECORD`). ## Deprecation - After 1.0, something is deprecated for at least one MINOR release before it is removed. It keeps working during that time, and the CHANGELOG's **Deprecated** section names the replacement. It is removed under **Removed** in the next MAJOR. - Before 1.0, a MINOR release may remove things right away, but we follow the same procedure when we can. ## MSRV (minimum supported Rust version) - **Rust 1.88** (`rust-version` in `Cargo.toml`). The dependency `napi` 3 requires 1.88. The edition is 2024. - The MSRV step of `scripts/check.sh` runs `cargo check --locked` on the whole workspace with exactly 1.88.0 (`rustup run 1.88.0`). - The MSRV is raised only in a MINOR release and noted under Changed in the CHANGELOG. When raising it, change `rust-version` in `Cargo.toml`, the MSRV step of `scripts/check.sh` and this document in the same commit. - Platform: macOS, and Linux x86_64 (glibc 2.29 or later). The checks (`scripts/check.sh`) run on the maintainer's Apple Silicon Mac; the Linux tests run in CI (`test-linux` in `.github/workflows/release.yml`). ## Examples | Change | Classification | Commit | |---|---|---| | Add a `-C` flag to `capture-pane` | New feature | `feat(cli): support capture-pane -C` | | Change the `wait-pane` timeout exit code from 1 to 2 | Breaking change | `fix(cli)!: exit 2 when wait-pane times out` | | `display -p '#{version}'`, which failed (1) without a session, now prints the server version (0); other formats still give 1 | New feature (only input that used to be an error now works; the results of input that already succeeded are unchanged) | `feat(cli): add the #{version} format variable` | | `display -p -t =gone: '...'`, which failed (1), now prints the format with the session's variables empty (0), as tmux does | Bug fix (input that used to be an error now works as in tmux; nothing that succeeded changes) | `fix(cli): answer display-message on a missing target as tmux does` | | The exit code or output format of a command that already succeeded changes | Breaking change | `feat(cli)!: ...` | | Add `zeropty_command_len()` to the C ABI | New feature | `feat(c-abi): add zeropty_command_len` | | Add an argument to `zeropty_open` | Breaking change (adding a new function is recommended instead) | `feat(c-abi)!: ...` | | Add a `"pid"` field to the reply JSON | New feature | `feat(protocol): ...` | | `RECORD_VERSION` to 2, still reads 1 | New feature | `feat(handover): ...` | | `RECORD_VERSION` to 2, cannot read 1 | Breaking change | `feat(handover)!: ...` | | Make `capture-pane -e` bytes that differed from tmux on the same screen match | Fix | `fix(term): ...` | | Screen parsing twice as fast | Fix (performance) | `perf(term): ...` | | Rename `ZEROPTY_SOCK` | Breaking change | `refactor!: ...` or `feat!: ...` | | MSRV 1.88 → 1.90 | MINOR | `build: raise MSRV to 1.90` | --- <!-- https://zeropty.pubdoc.dev/ko/guide/benchmarks --> # 벤치마크 모든 측정값이에요. 10배에 못 미친 항목(⛔)과 그 이유, 시도했다가 넣지 않은 것까지 그대로 적어요. README에는 이 중 [한 가지 규칙](/ko/guide/benchmarks#배수-계산-방법)으로 범위 전체가 10배 이상인 항목만 올려요. ## 요약: 전체 항목 **측정한 15개 항목 중 11개는 범위 전체가 tmux보다 10배 이상 좋아요.** 나머지 4개(⛔)는 범위의 아래 끝이 1.6\~8.9배예요. 하한의 원인은 아래 [10배 목표 현황](/ko/guide/benchmarks#10배-목표-현황-전체)에 적었어요. 모든 값은 같은 기기(Mac mini M1)에서 tmux와 번갈아 잰 실측이에요. 11개 중 하나는 tmux가 느려져서 10배를 넘었어요. 5.4MB 출력이 화면에 보이기까지는 2026-10-18에 경계(9.9\~11.7×)였고 2026-10-21에는 10.5\~12.5×예요. zeropty는 두 날 모두 같았고(14\~16ms, 그다음 15\~16ms), tmux가 더 바쁜 기기에서 159\~164ms에서 169\~188ms가 됐어요. [규칙](/ko/guide/benchmarks#배수-계산-방법)상 ✅이지만 README에는 올리지 않아요. | 무엇을 | tmux | zeropty | 배수 | 줄어든 비율 | |---|---|---|---|---| | CPU, 실제 claude 작업 400개 | 54.30\~54.91% | 3.51\~3.84% | **14.1\~15.6×** | **−92\~93%** | | 메모리, 실제 claude 작업 400개 (독립 서버) | 32.4\~32.5MB | 2.66\~2.70MB | **12.0\~12.2×** | **−91%** | | 메모리, 실제 claude 작업 400개 (임베디드, polled) | 32.4\~32.5MB | 1.63\~1.64MB | **19.7\~19.9×** | **−94%** | | 메모리, 대기 400개 (임베디드, polled) | 8.0MB | 0.45\~0.46MB | **17.3\~17.7×** | **−94%** | | 메모리, 대기 200개 (임베디드 + 서버 플러그인, CLI·attach로도 접근) | 5.0\~5.1MB | 388\~389KB | **12.8\~13.1×** | **−92%** | | 메모리, 화면 갱신 400개 | 251.2\~251.4MB | 1.71\~1.74MB | **144\~147×** | **−99%** | | 메모리, 대기 400개 (독립 서버) | 8.0MB | 1.25\~1.27MB | 6.2\~6.4× ⛔ | −84% | | `send-keys`, tmux 명령 실행 vs zeropty 소켓 (Node) | 9.03\~9.56ms | 0.24\~0.26ms | **34.7\~39.8×** | **−97%** | | `send-keys`, tmux 제어 모드(`-C`) vs zeropty 임베디드 (Node) | 0.42\~0.49ms | 0.06\~0.07ms | 6.0\~8.1× ⛔ (회차별 7\~8× †) | −83\~87% | | `send-keys`, tmux 제어 모드(`-C`) vs zeropty 소켓 (Node) | 0.42\~0.49ms | 0.24\~0.26ms | 1.6\~2.0× ⛔ (회차별 1.7\~2.0× †) | −38\~51% | | 키 입력 → 화면, tmux 제어 모드(`-C`) vs zeropty 임베디드 (Node) | 0.89\~0.98ms | 0.09\~0.10ms | 8.9\~10.8× ⛔ (회차별 9\~10× †) | −88\~90% | | 셸에서 `has-session`, tmux vs zeropty zsh 함수 | 6.04\~6.19ms | 0.11\~0.17ms | **35.5\~56.2×** | **−97\~98%** | | 셸에서 `send-keys`, tmux vs zeropty bash builtin | 6.02\~6.31ms | 0.043\~0.048ms | **125\~146×** | **−99%** | | 5.4MB 출력, 프로그램이 끝나기까지 | 166\~176ms | 3.0\~4.6ms | **36.0\~58.6×** | **−97\~98%** | | 5.4MB 출력, 화면에 보이기까지 | 169\~188ms | 15\~16ms | **10.5\~12.5×** | **−90\~92%** | | 세션 수 한도 | 511개 (PTY 기본 한도, 최대 999) | 1000개 확인, PTY 한도 없음 | – | – | ### 배수 계산 방법 이 문서의 모든 배수와 줄어든 비율은 한 가지 규칙을 따라요. - **표에 적힌 값 범위의 양끝으로 계산해요.** 아래 끝은 가장 빠른(작은) tmux 값 ÷ 가장 느린(큰) zeropty 값, 위 끝은 그 반대예요. 값이 하나면 배수도 하나예요. 회차끼리 짝지은 값은 남기지 않았으니, 표만 보고 누구나 같은 배수를 다시 계산할 수 있게 했어요. - **올리지 않고 버려요.** 100배 미만은 소수 첫째 자리 아래를, 100배 이상은 소수점 아래를 버려요(13.96 → 13.9, 141.6 → 141). 줄어든 비율도 정수로 버려요. 단위가 섞이면(MB와 KB) 1MB = 1000KB로 계산해 작은 쪽 배수를 적어요. - **10배 판정도 이 규칙 하나로 해요.** 범위의 아래 끝이 10배 이상이어야 ✅이고 README에 올라가요. 아래 끝이 10배 미만이면 위 끝이 10배를 넘어도 ⛔예요. - **참고로만 적는 값: `bench/latency.mjs`가 직접 출력한 배수(†).** 스크립트는 회차마다 같은 회차의 반올림 전 p50끼리 나눈 배수를 출력해요(임베디드 열은 정수로, 소켓 열은 소수 첫째 자리로 반올림). Node 응답 속도 표의 오른쪽 세 열과 동기 클라이언트 배수가 그 세 회차의 범위예요. 회차별 p50 값은 남기지 않아서 표만으로는 다시 계산할 수 없으니, 판정에는 쓰지 않고 † 표시를 붙여 함께 적기만 해요. 예를 들어 임베디드 `send-keys`(tmux 실행 대비)는 회차별로 140\~151×이고, 값 범위 양끝으로 나누면 128\~159×예요. 키 입력 → 화면(tmux -C 대비, 임베디드)은 어떤 회차에서는 10×였지만(회차별 9\~10×) 값 범위 양끝으로는 8.9\~10.8×라 ⛔예요. > 이 문서의 수치는 항상 최신 측정값이에요. 수치가 바뀌는 변경은 같은 커밋에서 이 문서와 README의 표를 갱신해요. **측정 환경** | 항목 | 값 | |---|---| | 날짜 | 2026-10-21 (서버 메모리·CPU, 서버 플러그인을 켜고 끈 임베디드 메모리, Node 응답 속도 세 번, 셸, 대량 출력, `wait-pane`, `grep-panes`), 2026-10-19 (C 클라이언트·서버 플러그인 응답 속도, polled 이벤트 루프 막힘) | | 커밋 | `9694aa3`에 이 변경의 `bench/` 스크립트 (2026-10-21 측정) | | 기기 | Apple M1 (P코어 4 + E코어 4), 16GB | | OS | macOS 26.6.2 | | 비교 대상 | tmux 3.7c | **측정 조건** - 세션 설정: 220×80, 스크롤백 2000줄. tmux도 같은 크기이고, 스크롤백은 tmux 기본값 2000줄이에요. - 메모리: 20초 동안 2초마다 잰 footprint 10개의 평균이에요. - CPU: 같은 구간의 실제 경과 시간으로 나눈 값이에요. - 실제 작업 재생: 세션 i가 녹화본의 (i × 0.618 mod 1) 지점부터 시작해서, 회차와 서버가 달라도 같은 부하예요. - 같은 설정을 반복하면 메모리는 ±1.5%, CPU는 ±5% 정도 흔들려요(서버가 효율 코어에서 도는 일이 많아요). - 2026-10-21에는 기기에서 다른 프로그램이 돌고 있었어요(load average 약 2.9). 그래서 응답 속도와 셸 측정에서 tmux와 zeropty 모두 이전 날짜보다 느렸어요. 그래도 짝은 모두 같은 부하에서 번갈아 쟀고, 배수는 그 짝끼리 비교한 값이에요. ## 메모리 · CPU: 독립 서버 (`bench/suite.sh`) | 부하 | 세션 | tmux 메모리 | zeropty 메모리 | 배수 | tmux CPU | zeropty CPU | 배수 | |---|---|---|---|---|---|---|---| | 대기 (claude 화면 1장 출력 후 정지) | 200 | 5.0\~5.1MB | 1.20MB | 4.1\~4.2× ⛔ | 0% | 0% | – | | | 400 | 8.0MB | 1.25\~1.27MB | 6.2\~6.4× ⛔ | 0% | 0% | – | | 화면 갱신 (1초마다 31줄 다시 그림) | 200 | 121.0\~121.3MB | 1.57\~1.61MB | **75.1\~77.2×** | 18.21\~18.31% | 1.02\~1.12% | **16.2\~17.9×** | | | 400 | 251.2\~251.4MB | 1.71\~1.74MB | **144\~147×** | 28.71\~29.79% | 1.85\~2.09% | **13.7\~16.1×** | | claude 기록 통째 출력 (15KB, 2초마다) | 200 | 7.3\~7.7MB | 1.71\~1.73MB | 4.2\~4.5× ⛔ | 26.74\~26.83% | 1.51\~1.61% | **16.6\~17.7×** | | | 400 | 12.5\~13.6MB | 1.95\~2.30MB | 5.4\~6.9× ⛔ | 42.29\~42.82% | 2.96\~2.97% | **14.2\~14.4×** | | 실제 claude 작업 재생 (`tests/streams/claude2.trace`) | 200 | 17.4MB | 1.65\~1.66MB | **10.4\~10.5×** | 36.04\~36.67% | 2.24\~2.29% | **15.7\~16.3×** | | | 400 | 32.4\~32.5MB | 2.66\~2.70MB | **12.0\~12.2×** | 54.30\~54.91% | 3.51\~3.84% | **14.1\~15.6×** | | 대기 | 1000 | 불가 (PTY 한도) | 1.38\~1.39MB | – | 불가 | 0% | – | | 실제 claude 작업 재생 | 1000 | 불가 (PTY 한도) | 4.65MB | – | 불가 | 6.69\~7.10% | – | 2026-10-21에 `bench/suite.sh`를 두 번 돌린 값이고, 범위는 두 회차예요. 여기서 MB는 footprint KB ÷ 1000이에요. - **실제 작업 200개가 이제 10배를 넘어요(2026-10-19에는 8.4\~9.0×).** tmux는 17.4MB 그대로인데 zeropty가 1.92\~2.05MB에서 1.65\~1.66MB가 됐어요. claude 기록 통째 출력 200개의 CPU는 경계(9.9\~10.2×)에서 16.6\~17.7×가 됐어요. tmux CPU가 늘고(17.9% → 26.74\~26.83%) zeropty는 줄었어요(1.75\~1.80% → 1.51\~1.61%). 둘 다 원인은 찾아보지 않았어요. **세션 1000개의 출력이 다 흐르게 고쳤어요(2026-10-18).** 이전 측정(4.66%)은 출력의 59%만 흐르던 상태였어요. 원인은 macOS 파이프였어요. - macOS는 파이프 버퍼를 시스템 전체 16MB에서 나눠 줘요. 64KB까지 커진 파이프가 약 250개 생기면, 나머지 파이프는 모두 **512바이트**만 받아요(직접 만든 파이프 3000개로 확인). - 512바이트 파이프는 `fstat`이나 kevent로도 구별되지 않아요(둘 다 16KB라고 해요). 그런 세션의 프로그램은 300ms 읽기마다 512바이트만 쓰고 기다렸어요. - 세션 출력을 **socketpair**(버퍼 64KB, 세션마다 따로)로 바꾸자, 1000개 재생에서 서버가 읽은 양이 녹화 속도 대비 59%에서 **102%**가 됐어요. 200·400개는 전후 모두 101%예요. - 그래서 1000개의 CPU는 더 많은 출력을 처리한 값이에요. - **대가:** zeropty가 읽기를 멈추면 세션마다 최대 64KB가 커널 소켓 버퍼(mbuf)에 쌓여요. 1000개면 64MB예요(이 기기의 mbuf 풀은 256MB). 파이프는 시스템 전체 16MB가 상한이었어요. **⛔ 표시는 서버 프로세스 하한 때문에 10배가 안 되는 항목이에요.** 세션 1개인 zeropty 서버도 1.09MB예요(2026-10-19). 아무것도 안 하는 C 프로그램이 0.87MB, `perl` 프로세스가 1.36MB, tmux 빈 서버가 2.2MB예요. - 빈 C 프로그램 0.865MB도 대부분 운영체제 몫이에요(`footprint`로 분해): 커널 페이지 테이블 209KB(XNU는 이것도 footprint에 넣어요), 시스템 malloc 메타데이터 약 200KB, 라이브러리가 고쳐 쓴 데이터(`__DATA_DIRTY`, `__DATA`, shared cache) 약 240KB, 스택과 malloc 영역. 링크 옵션(`-no_fixup_chains`, `-dead_strip`, 작은 스택)으로는 잡음 수준(±16KB)만 움직였고, Apple의 `/bin/sleep`도 832KB예요. - **줄일 수 있었던 건 시스템 malloc 설정이에요(2026-10-19 적용).** 서버는 `MallocNanoZone=0 MallocMaxMagazines=1 MallocSpaceEfficient=1 MallocMediumZone=0`으로 자신을 다시 실행해요(libmalloc은 시작할 때만 읽어요). 화면은 mimalloc이 맡고 시스템 malloc은 libSystem 몫만 하니, 스레드 하나에 맞게 영역과 CPU별 캐시를 줄여도 돼요. 모든 부하에서 144\~240KB가 줄었어요(세션 1개 1233 → 1089KB, 대기 200개 1.39\~1.44 → 1.23MB, 실제 작업 400개 2.96\~3.04 → 2.72\~2.82MB, 번갈아 2회). CPU는 같은 분포였어요(실제 작업 400개, 번갈아 3회: 1.47/1.53/4.01% vs 1.47/3.85/3.86%, 서버가 어느 코어에서 도느냐로 두 값을 오가요). 세션의 프로그램에는 이 설정을 물려주지 않아요. - zeropty 몫은 mimalloc이 잡은 384KB예요. 실제 힙은 8KB인데, 오래 사는 작은 객체들이 크기 등급별로 다른 16KB 쪽에 흩어져 쪽마다 통째로 잡혀요. mimalloc 설정(eager commit, arena 크기)은 효과가 없었어요. `MallocNanoZone=0` 하나만으로는 16KB뿐이고, 위의 네 설정을 함께 써야 줄어요. 예를 들어 대기 400개가 10배가 되려면 0.80MB 이하여야 해서 빈 프로세스보다 작아야 해요. **서버를 다른 언어로 쓰면 넘을 수 있을까요?** 빈 서버를 직접 만들어 쟀어요(2026-10-18, Mac mini M1). | 빈 서버 | 메모리 | |---|---| | 시스템 콜만 쓰는 정적 바이너리 (libSystem 없음) | 실행 불가: Apple Silicon macOS 커널이 바로 SIGKILL해요 | | C, `pause()`만 / kqueue와 소켓 서버 | 0.865MB / 0.865MB | | Rust, 빈 `main` | 0.93MB | | **zeropty 빈 서버** | **1.15MB** | | Go, 소켓 서버 | 2.59MB | C로 다시 짜면 빈 서버에서 약 0.29MB를 아낄 수 있어요. 하지만 C 빈 프로세스(0.865MB)가 이미 대기 400개의 10배 기준(0.80MB)보다 커요. 그래서 **별도 프로세스로는 어떤 언어로도 불가능해요.** 이 하한을 넘는 방법이 아래 **임베디드 모드**예요. 따로 프로세스를 두지 않으니 프로세스 하한이 없어요. **세션 수가 200에서 400으로 늘 때 세션 하나당 늘어난 메모리 (독립 서버):** | 부하 | tmux | zeropty | 배수 | |---|---|---|---| | 대기 | 14.9\~15.0KB | 0.24\~0.32KB | **46.5\~62.5×** | | 화면 갱신 | 649\~652KB | 0.54\~0.83KB | **781\~1207×** | | claude 기록 통째 출력 | 23.8\~31.8KB | 1.1\~3.0KB | 7.9\~28.9× ⛔ | | 실제 claude 작업 | 74.8\~75.3KB | 5.0\~5.3KB | **14.1\~15.0×** | 두 회차(2026-10-21)의 footprint KB로 계산했고, 다른 곳처럼 범위 양끝을 짝지었어요. claude 기록 통째 출력이 ⛔인 건 zeropty 400개 footprint가 두 회차에서 352KB 달랐기 때문이에요(1948KB와 2300KB). 2026-10-19에는 13.2×였어요. **PTY 사용량:** 세션 400개일 때 tmux는 435개, zeropty는 35개예요. 35개는 시스템 기본값이라 세션 1000개에서도 그대로예요. ## 메모리: 임베디드 (엔진을 내 프로세스 안에, `bench/embed_host.c`) 다른 일을 하지 않는 C 프로그램에 엔진을 넣고, 엔진을 켜기 전과 세션을 띄운 뒤의 footprint 차이를 쟀어요. Node처럼 자체 힙이 수 MB씩 움직이는 호스트에서는 이렇게 정확히 잴 수 없어서 C 호스트를 써요. 엔진은 두 방식으로 돌 수 있어요. **엔진 스레드**(`zeropty_open`, Node `{ polled: false }`)는 엔진이 자기 스레드를 가져요. **polled**(`zeropty_open_polled`, Node 기본값)는 스레드 없이 호스트의 이벤트 루프가 엔진을 돌려요. 값은 2026-10-21에 방식마다 두 번 잰 범위이고, tmux 서버 열은 위 독립 서버 표의 값이에요. | 부하 | 세션 | tmux 서버 | zeropty 엔진 스레드 | 배수 | zeropty polled | 배수 | |---|---|---|---|---|---|---| | 대기 | 200 | 5.0\~5.1MB | 0.68\~0.69MB | 7.2\~7.5× ⛔ | **0.39MB** | **12.8\~13.0×** | | | 400 | 8.0MB | 0.74MB | **10.8×** | **0.45\~0.46MB** | **17.3\~17.7×** | | 실제 claude 작업 재생 | 200 | 17.4MB | 1.65\~1.66MB | **10.4\~10.5×** | **0.95\~1.32MB** | **13.1\~18.3×** | | | 400 | 32.4\~32.5MB | 2.25\~2.33MB | **13.9\~14.4×** | **1.63\~1.64MB** | **19.7\~19.9×** | - **엔진 스레드를 켜는 것만으로 +308\~309KB예요.** 스레드의 할당기 힙, 스택, 페이지 테이블 몫이에요. polled는 +180\~181KB예요. - **polled 대기 200개가 0.31MB(16.4×, 2026-10-18)에서 0.39MB(12.8\~13.0×)가 됐어요.** 엔진 시작이 +149KB에서 +180\~181KB로, 세션 200개 몫이 +160KB에서 +208KB로 늘었어요. 그 사이 어떤 변경이 늘렸는지는 아직 찾지 못했어요. - **polled는 세션 몫도 작아요.** 모든 할당이 호스트 스레드의 힙 하나에 모이기 때문이에요(대기 200개: 368\~384KB → 208KB). - **polled의 비용:** 세션 출력 파싱을 호스트 스레드가 해요(실제 작업 400개에서 코어 하나의 \~3.5%). 명령은 모두 그 스레드에서 불러야 해요(Node는 원래 JS 스레드 하나라 제약이 없어요. worker 스레드에서 부를 때만 `{ polled: false }`). - **대량 출력은 조각내서 파싱해요.** polled는 출력 덩어리를 256KB씩, 한 번에 최대 2ms만 읽고 호스트 루프에 차례를 돌려줘요. 바쁜 세션 200개에 5.4MB 출력이 들어올 때 Node 이벤트 루프가 가장 길게 막힌 시간이 11\~32ms에서 3\~6ms가 됐어요(`bench/loop_block.mjs`, 번갈아 3회). 화면에 다 들어오는 시간과 메모리는 그대로예요. 엔진 스레드 방식도 CPU를 두고 다투느라 최대 30ms 막혔어요. - 세션 하나당은 대기 0.24\~0.32KB, 실제 작업 3.0\~3.4KB예요(엔진 스레드 방식, 세션 200 → 400개). - **트레이드오프:** 10초 넘게 조용한 화면은 zstd로 압축해 이름 없는 임시 파일에 둬요. 프로세스 메모리에서는 빠지지만 OS 페이지 캐시에는 남아요(필요하면 OS가 회수해요). `ZEROPTY_COLD=memory`로 메모리에 두게 할 수 있어요. **서버 플러그인을 켜면 (`listen`, `bench/embed_host.c ... polled <소켓>`, 2026-10-21, 켬·끔 번갈아 2회).** 같은 C 호스트에서 소켓을 열었을 때와 안 열었을 때를 쟀어요. | 부하 | 세션 | tmux 서버 (위 표) | 플러그인 없음 | 플러그인 켬 | tmux 서버 대비 (켬) | |---|---|---|---|---|---| | 엔진만 | 0 | – | +180\~181KB | +180\~181KB | – | | 대기 | 200 | 5.0\~5.1MB | 388KB | 388\~389KB | **12.8\~13.1×** | | 실제 claude 작업 재생 | 200 | 17.4MB | 949\~1322KB | 973\~1248KB | **13.9\~17.8×** | | | 400 | 32.4\~32.5MB | 1630\~1636KB | 1395\~1740KB | **18.6\~23.2×** | - **플러그인 자체는 이제 0\~1KB예요**(2026-10-19에는 소켓 하나와 연결 목록으로 +16\~18KB). 같은 기간에 polled 시작 비용이 약 31KB 늘었으니, `listen`이 더하던 것을 이제 엔진이 시작할 때 마련하는 것일 수 있어요. 확인하지는 않았어요. 실제 작업 부하의 차이는 회차마다 위아래로 바뀌는 잡음 범위예요. - **그래서 CLI·attach·소켓 클라이언트를 쓰면서도 메모리는 임베디드 수준이에요.** 같은 대기 200개를 독립 서버로 두면 1.20MB(4.1\~4.2× ⛔)예요. 차이는 서버 프로세스 하나의 몫이에요. - **주의:** 이 값은 호스트 프로그램이 원래 있다는 전제의 증가분이에요(호스트 자체 \~1.08MB 제외). 세션만 두려고 호스트를 따로 띄운다면 그건 독립 서버와 같아요. ## 응답 속도 (`node bench/latency.mjs 200`) Node 프로그램이 세션 서버를 부르는 네 가지 방식을 나란히 쟀어요. | 방식 | 설명 | |---|---| | tmux | Node `execFile`로 명령마다 tmux 실행 (프로그램이 tmux를 부르는 흔한 방식) | | tmux -C | 제어 모드: tmux 클라이언트 하나를 계속 열어 두고 stdin으로 명령 전송 (tmux를 가장 빠르게 쓰는 방법) | | zeropty | 연결 하나를 유지하며 서버 소켓에 직접 요청 (`client/zeropty.mjs`) | | zeropty 임베디드 | 엔진을 Node 프로세스 안에 넣고 함수로 호출 (`client/embedded.mjs`) | 서버마다 바쁜 세션 200개를 띄우고, 요청을 **번갈아** 보냈어요. 항목마다 40회씩이고, 2026-10-21에 세 번 실행한 p50(ms)의 범위예요. 임베디드 열은 Node 기본값인 polled 방식이에요. 오른쪽 세 배수 열은 `bench/latency.mjs`가 회차마다 출력한 배수의 범위(†)예요. 10배 판정은 그 아래 표의, 값 범위 양끝으로 나눈 배수로 해요([배수 계산 방법](/ko/guide/benchmarks#배수-계산-방법)). | 항목 | tmux | tmux -C | zeropty | zeropty 임베디드 | 임베디드 vs tmux † | 임베디드 vs tmux -C † | zeropty vs tmux -C † | |---|---|---|---|---|---|---|---| | `capture-pane` (바쁜 세션) | 9.31\~9.90 | 0.55\~0.63 | 0.25\~0.30 | 0.10\~0.17 | **57\~93×** | 3\~5× ⛔ | 1.8\~2.5× ⛔ | | `capture-pane -e -S -200` (조용한 세션) | 9.37\~10.14 | 0.45\~0.60 | 0.23\~0.27 | 0.06\~0.07 | **134\~172×** | 7\~10× ⛔ | 2.0\~2.3× ⛔ | | `send-keys` | 9.03\~9.56 | 0.42\~0.49 | 0.24\~0.26 | 0.06\~0.07 | **140\~151×** | 7\~8× ⛔ | 1.7\~2.0× ⛔ | | 키 입력 → 화면에 보이기까지 | 24.62\~25.24 | 0.89\~0.98 | 0.35\~0.43 | 0.09\~0.10 | **250\~268×** | 9\~10× ⛔ (값 범위로는 8.9\~10.8×) | 2.1\~2.8× ⛔ | 같은 p50 범위를 [한 가지 규칙](/ko/guide/benchmarks#배수-계산-방법)(값 범위의 양끝으로 나누고 버림)으로 계산한 배수예요. 10배 판정은 이 표로 해요. | 항목 | zeropty vs tmux | 임베디드 vs tmux | 임베디드 vs tmux -C | zeropty vs tmux -C | |---|---|---|---|---| | `capture-pane` (바쁜 세션) | **31.0\~39.6×** | **54.7\~99.0×** | 3.2\~6.3× ⛔ | 1.8\~2.5× ⛔ | | `capture-pane -e -S -200` (조용한 세션) | **34.7\~44.0×** | **133\~169×** | 6.4\~10.0× ⛔ | 1.6\~2.6× ⛔ | | `send-keys` | **34.7\~39.8×** | **128\~159×** | 6.0\~8.1× ⛔ | 1.6\~2.0× ⛔ | | 키 입력 → 화면에 보이기까지 | **57.2\~72.1×** | **246\~280×** | 8.9\~10.8× ⛔ | 2.0\~2.8× ⛔ | - **임베디드 열은 polled 방식이고, 엔진 스레드 방식도 같은 수준이에요.** 2026-10-21에 번갈아 두 번씩 잰 polled 대 엔진 스레드 p50: 바쁜 세션 capture 0.08\~0.09 vs 0.08\~0.11, 조용한 세션 capture 0.06\~0.07 vs 0.05\~0.07, `send-keys` 0.06 vs 0.06, 키→화면 0.10 vs 0.08\~0.09. 그러니 임베디드 `send-keys`가 0.04\~0.06ms(2026-10-18, 엔진 스레드)에서 0.06\~0.07ms가 된 건 방식 때문이 아니에요. 기기가 더 바빴고, 더 확인하지는 않았어요. - **임베디드 `send-keys`(tmux -C 대비)가 라인 디시플린을 넣으며(2026-10-18) 회차별 10\~11배에서 8\~10배가 됐고(†, ⛔), 2026-10-21에는 회차별 7\~8배예요.** 측정 세션(`cat`)이 이제 PTY처럼 cooked 모드예요. 그래서 키마다 모드 확인 읽기 한 번과 에코 처리가 더해졌어요(키 하나에 약 10µs). tmux도 같은 에코를 처리하지만 서버 쪽에서 send-keys가 돌아간 뒤에 해요. raw 모드 세션(claude)에는 더해지는 일이 없어요(`examples/sendkeys_bench.rs`, 변경 전과 같음). shim이 모드를 출력 대신 공유 메모리로 알려 주면 모드 확인 읽기를 없앨 수 있지만 넣지 않았어요. 얻는 건 cooked 세션의 키 하나당 약 8µs(25ms 간격, cooked 22.9µs vs raw 14.7µs)뿐이고, 실제 대상인 claude는 이미 raw예요. - **임베디드 `capture-pane`(바쁜 세션)이 크게 흔들리는 이유를 쪼개 봤어요(회차별 2026-10-18 5\~12배, 2026-10-21 3\~5배. 분석은 2026-10-18, 바쁜 세션 200개).** - 같은 화면을 연달아 capture하면 5.9µs예요. 25ms 간격으로 부르면 렌더링만 27µs예요. 쉬다 깨어난 CPU의 클럭과 캐시 때문이에요. 세션 1개로 재도 같았고, 부르는 스레드의 QoS를 올려도 거의 같았어요. - 바쁜 세션은 300ms 동안 모은 출력을 capture가 그 자리에서 파싱해요. p50 15\~30µs, 8\~12KB가 쌓였으면 최대 0.5ms예요. 엔진 스레드가 다른 세션을 읽는 중이면 잠금을 기다려요(p99 최대 0.5ms). - 바뀌지 않은 화면의 capture 결과를 저장해 두면 식은 상태에서도 렌더링이 빠지지만, 넣지 않았어요. 화면 하나에 약 17KB라 세션 1000개면 17MB가 늘어요. - 최근 capture한 세션을 바로 읽게 하면 이 부분이 사라지지만, 넣지 않았어요. 폴링하는 호스트는 폴링 간격(수백 ms)이 지배적이라 체감 이득이 없고, CPU가 늘어요(바로 읽기: 세션 200개에 +0.8%p). 빨리 알아야 하는 경우는 `wait-pane`이 있어요. - **배수가 흔들리는 이유:** tmux -C가 0.4\~0.6ms, 임베디드가 0.05ms 안팎이라 0.01ms 차이에도 배수가 2\~3씩 움직여요. 그래서 한 번의 값이 아니라 범위로 적었어요. 바쁜 세션 capture는 그동안 쌓인 출력을 먼저 읽고 화면을 그리는 비용이 들어 있어요. - **zeropty 소켓 방식은 명령마다 tmux 실행 대비 31.0\~72.1배예요**(위 두 번째 표의 첫 열). - **소켓 방식(zeropty)은 tmux -C 대비 1.6\~2.8배예요(⛔, 위 두 번째 표의 마지막 열. 회차별로는 1.7\~2.8× †).** Node에서 Unix 소켓 왕복 자체가 하한이에요(아무 일도 안 하는 에코 서버가 0.33ms). - **C 프로그램에서 재도 비율은 1.7\~3.3배예요(`bench/latency_c.c`, 2026-10-18).** Node를 빼면 tmux -C도 같이 빨라지기 때문이에요. | C 클라이언트에서 (p50) | tmux -C | zeropty 소켓 | 배수 | |---|---|---|---| | `send-keys` | 22\~35µs | 10.5\~12.5µs | 1.7\~3.3× ⛔ | | `capture-pane` (바쁜 세션) | 40µs | 13\~15µs | 2.6\~3.0× ⛔ | 둘 다 프로세스 사이 왕복 한 번이 바닥이에요. tmux -C 대비 10배가 되려면 3.5µs 이하여야 하는데, 소켓 왕복 하나(문맥 전환 두 번)가 그보다 커요. 그래서 언어를 바꿔도 안 되고, 왕복을 없애는 임베디드 모드만 10배를 넘어요. - **위 표는 요청을 쉬지 않고 연달아 보낸 값이에요.** 실제로는 요청 사이에 쉬는 시간이 있어요. 그래서 요청 전에 쉬는 간격을 두고 다시 쟀어요(`latency_c ... 300 <간격 µs>`, 2026-10-19, 바쁘지 않은 `cat` 세션이 아니라 claude 재생 세션). 쉬었다 보내면 둘 다 느려지고 배수는 1.7배로 줄어요. | C 클라이언트, p50 | tmux -C | zeropty 소켓 | 배수 | |---|---|---|---| | `send-keys`, 연달아 | 26.1µs | 9.3µs | 2.8× ⛔ | | `send-keys`, 1ms 쉬고 | 65.5µs | 36.7µs | 1.7× ⛔ | | `send-keys`, 25ms 쉬고 | 99.2µs | 56.4µs | 1.7× ⛔ | | `capture-pane`, 25ms 쉬고 | 108.2µs | 62.3µs | 1.7× ⛔ | - **서버 플러그인(임베디드 호스트가 연 소켓)도 독립 서버와 같은 속도예요.** `bench/listen_host.c`(polled 엔진이 소켓을 엶)와 `zeropty serve`를 같은 C 클라이언트로 번갈아 2회 쟀어요(2026-10-19). 이날은 기기가 더 느린 상태여서 tmux -C도 위 표의 2배쯤 나왔고, 배수는 비슷해요. | C 클라이언트, p50 | tmux -C | 독립 서버 | 서버 플러그인 | |---|---|---|---| | `send-keys`, 연달아 | 26.6\~44.6µs | 12.0\~20.4µs | 12.4\~22.2µs | | `capture-pane`, 연달아 | 41.5\~74.1µs | 14.6\~27.3µs | 14.2\~28.7µs | | `send-keys`, 25ms 쉬고 | 224\~252µs | 120\~124µs (1.8\~2.1×) | 126\~130µs (1.7\~2.0×) | | `capture-pane`, 25ms 쉬고 | 297\~360µs | 143\~149µs (1.9\~2.5×) | 148\~160µs (1.8\~2.4×) | 쉬었다 보낼 때 플러그인이 3\~8% 느려요. 원인은 아직 확인하지 않았어요. polled 호스트는 엔진 fd를 `poll`로 기다린 뒤 `zeropty_process`에서 kqueue를 한 번 더 읽어서, 시스템 호출이 하나 더 있어요. - **쉬었다 보낼 때의 바닥은 "통로"가 아니라 잠든 코어를 깨우는 비용이에요(2026-10-19, C로 직접 잼).** 두 프로세스가 1바이트를 주고받는 왕복을 방식별로 쟀어요. 연달아 보내면 방식마다 0.7\~10µs로 다르지만, 1ms만 쉬어도 모두 12\~21µs, 25ms 쉬면 28\~34µs가 돼요. 코어를 계속 돌리는 spin만 0.08µs인데, 코어 하나를 100% 써요. | 왕복 방식 (p50) | 연달아 | 1ms 쉬고 | 25ms 쉬고 | |---|---|---|---| | socketpair (zeropty가 쓰는 방식) | 9.7µs | 17.6µs | 33.6µs | | pipe | 5.6µs | 12.2µs | 28.2µs | | 공유 메모리 + `__ulock` 대기 | 6.5µs | 20.5µs | 28.2µs | | 공유 메모리 + 200µs spin 후 대기 | 0.7µs | 13.3µs | 28.3µs | | `mach_eventlink` (커널이 상대 스레드로 바로 넘김) | 2.1µs | 14.0µs | – | | 공유 메모리 + 계속 spin | 0.08µs | 0.08µs | (코어 100%) | tmux -C가 25ms 쉬고 99µs이니 10배는 9.9µs인데, 이 바닥(28\~34µs)보다 작아요. 그래서 쉬었다 보내는 조건의 10배는 별도 프로세스로는 불가능해요. - **서버 안의 처리 시간도 식어서 늘어요.** 25ms 쉬고 보낸 `has-session`에서 서버가 요청을 받아 답을 쓰기까지 18.5µs였어요(연달아 보내면 2.4µs). 소켓 읽기, JSON 해석, 명령 실행, 답 쓰기가 골고루 6\~7배 느려졌어요. 특정 코드가 아니라 식은 CPU와 캐시 때문이에요. - **시도했지만 넣지 않은 것 (2026-10-19, 모두 번갈아 측정):** - 서버 스레드 QoS를 USER_INTERACTIVE로: 37.5\~39.9µs vs 39.1\~39.2µs, 차이 없음. - LTO + `codegen-units=1`(코드를 모아 식은 상태에서 덜 건드리게): 1\~2µs(3%), 잡음 수준. - kqueue가 알려 준 바이트 수만큼만 읽어서 "더 없음" 확인 읽기를 생략: 차이 없음. - 답한 뒤 서버가 200µs 동안 다음 요청을 기다리며 spin: 연달아 보내는 구간의 `send-keys`가 8.0µs에서 4.5µs(tmux -C 대비 5.8배)가 되지만, 쉬었다 보내면 그대로예요. 대신 요청마다 코어를 200µs씩 붙잡아 CPU 목표를 깎아요. 공유 메모리로 바꾸면 계산상 1.5\~2µs까지 가지만 역시 연속 구간에서만이라, µs 몇 개는 사람이나 claude의 속도에서 체감되지 않아요. - `mach_eventlink`: 스레드 하나가 링크 하나에서만 기다리는 방식이라 kqueue 하나로 모든 연결을 보는 서버에 넣으려면 연결마다 스레드가 필요해요(스레드마다 할당기 힙, 앞의 GCD 시험에서 +0.8MB). kqueue와 함께 쓸 수 있는 Mach 메시지는 소켓보다 2µs 빠를 뿐이에요. - **클라이언트에 화면 미러를 두는 방식(mosh, tmux `%output`처럼)은 넣지 않았어요(2026-10-19).** 클라이언트가 `attach -r`로 출력을 받아 화면을 직접 해석하면 capture에 왕복이 없어져요. 하지만 바쁜 세션 200개를 모두 미러링하자 서버 CPU가 2.50%에서 6.15%가 됐어요. 누가 보는 세션은 300ms씩 모아 읽지 않고 바로 읽어서 보내기 때문이에요. 클라이언트도 초당 573KB를 다시 해석해야 해요. 그리고 미러의 capture는 서버 capture와 달리 쌓인 출력을 확인하지 않아서, 빠른 만큼 오래된 화면일 수 있어요. 모아 읽기를 유지하면 CPU는 그대로지만 화면이 최대 300ms 늦어요. 화면을 계속 받는 용도는 `attach -r`, 변화를 기다리는 용도는 `wait-pane`이 이미 해요. - **동기 클라이언트(`zeroptySync`, `zeropty.node`의 `Client`)는 회차별 3.0\~4.1배예요(⛔ †, p50 0.13\~0.26ms, 같은 세 번).** 이벤트 루프를 거치지 않고 블로킹으로 쓰고 읽어서 p50이 비동기보다 약 25\~48% 낮아요. 남은 하한은 서버와 Node 두 프로세스가 번갈아 깨어나는 왕복이에요. 서버가 바쁠 때는 p95가 1ms까지 튀어요. - **임베디드는 그 하한이 없어요.** 남은 하한은 Node 스레드가 잠들었다 깨는 시간(\~40\~50µs)이에요. 연달아 부르면 한 번에 2.6µs예요. ## 응답 속도: 셸에서 (`bench/shell.zsh`) 셸 스크립트에서 명령 200번씩, 바퀴마다 번갈아 쟀어요. `bench/shell.zsh`를 세 번, 한 번에 두 바퀴씩 모두 여섯 바퀴예요(2026-10-21, Mac mini M1). 단위 ms(p50 / p95)이고, 범위는 여섯 바퀴의 값이에요. | 명령 | tmux | zeropty 프로그램 | zeropty zsh 함수 | zsh 함수 vs tmux | |---|---|---|---|---| | `has-session` | 6.04\~6.19 / 6.74\~7.20 | 3.59\~3.95 / 4.22\~4.85 | 0.11\~0.17 / 0.14\~0.38 | **35.5\~56.2×** | | `send-keys -l` | 6.11\~6.33 / 6.92\~9.86 | 3.68\~3.92 / 4.25\~5.39 | 0.12 / 0.15\~0.20 | **50.9\~52.7×** | | `capture-pane -p -S -2000` | 6.67\~6.75 / 7.41\~7.76 | 3.86\~4.23 / 4.47\~5.52 | 0.50\~0.55 / 0.57\~1.13 | **12.1\~13.5×** | - **두 프로그램 모두 2026-10-18보다 오래 걸렸어요**(tmux 4.35\~5.02ms → 6.04\~6.75ms, zeropty 프로그램 2.47\~2.60ms → 3.59\~4.23ms). 기기가 더 바빴어요. zsh 함수는 거의 그대로라(0.11\~0.48ms → 0.11\~0.55ms) 배수가 올라갔어요. - **프로그램끼리는 1.5\~1.7배예요.** 명령마다 프로세스를 띄우는 비용(빈 C 프로그램도 1.3ms)이 하한이에요. - **zsh 함수(`shell/zeropty.zsh`)는 그 하한을 피해요.** zsh에 기본으로 들어 있는 소켓 모듈(`zsh/net/socket`)로 셸 안에서 서버에 바로 말해서, 프로세스를 띄우지 않아요. - **bash는 builtin(`shell/zeropty_bash.c` → `dist/zeropty_bash.dylib`)으로 같은 일을 해요.** bash 3.2에는 µs 시계가 없어서 200번 도는 루프 전체 시간에서 빈 루프 시간을 뺀 평균이에요(같은 스크립트 끝부분). | 명령 (bash, 평균, 여섯 바퀴) | tmux | zeropty 프로그램 | zeropty bash builtin | builtin vs tmux | |---|---|---|---|---| | `has-session` | 5.968\~6.470ms | 3.630\~3.860ms | 0.041\~0.075ms | **79.5\~157×** | | `send-keys -l` | 6.022\~6.313ms | – | 0.043\~0.048ms | **125\~146×** | | `capture-pane -p -S -2000` | 6.733\~7.066ms | – | 0.114\~0.138ms | **48.7\~61.9×** | 위와 같은 여섯 바퀴예요. builtin `has-session`이 한 바퀴에서 2.448ms가 나왔고 나머지 다섯 바퀴는 0.041\~0.075ms였어요. 그 바퀴의 다른 명령은 정상이라 기기가 잠깐 멈춘 것으로 보고(확인하지는 않았어요) 범위에서 뺐어요. 넣으면 아래 끝이 2.4×예요. ## 대량 출력 프로그램이 5.4MB(색상 포함 6만 줄)를 최대한 빨리 출력해요(`bench/bulk.sh`, 2026-10-21, Mac mini M1, tmux와 번갈아 3번씩, 두 번). | 항목 | tmux | zeropty | 배수 | |---|---|---|---| | 프로그램이 출력을 끝내기까지 | 166\~176ms | 3.0\~4.6ms | **36.0\~58.6×** | | 프로그램이 출력을 시작해서 마지막 줄이 `capture-pane`에 보이기까지 | 169\~188ms | 15\~16ms | **10.5\~12.5×** | - **화면 시간이 10배를 넘은 건 tmux가 느려졌기 때문이에요.** 2026-10-18에는 159\~164ms 대 zeropty 14\~16ms(9.9\~11.7×, 경계)였어요. zeropty는 두 날 모두 같은 시간이 걸렸어요. - **출력이 파이프를 채우면 zeropty는 프로그램이 멈출 때까지 전부 먼저 읽고, 파싱은 그다음에 해요.** 그래서 프로그램이 파싱을 기다리지 않아요. 이전에는 16KB씩 읽고 파싱하는 동안 프로그램이 막혀서 17ms였어요. 하한은 같은 프로그램이 `cat > /dev/null`에 쓸 때의 1.6ms예요. - **화면 쪽은 스크롤되어 나가는 행의 압축을 한 번의 쓰기가 끝날 때까지 미뤄요.** 6만 줄 중 스크롤백(2000줄)에 남는 행만 압축해요. 5.4MB 파싱이 16.5ms에서 약 9ms가 됐어요(`cargo run --release --example parse_bench bulk`). - 화면 시간은 `capture-pane`을 쉬지 않고 불러서 재요. 매번 프로세스를 띄워서(zeropty \~2.5ms, tmux \~4.5ms) 그만큼 오차가 있어요. 이전 측정(31\~41ms)에는 `new-session`과 시각을 재는 perl 실행 비용이 더해져 있었어요. ## 기다리기: 폴링 대신 `wait-pane` (`node bench/wait.mjs 200`) 호스트가 "세션이 일을 끝냈는지"를 알아내는 두 방식을 비교했어요. 세션 200개가 실제 claude 작업(`tests/streams/claude2.trace`)을 5\~25초 재생한 뒤 `TASK-DONE`을 출력해요. - **폴링:** 호스트가 남은 세션을 P ms마다 `capture-pane`으로 읽어요. tmux 호스트는 이 방법밖에 없어요. - **`wait-pane`:** 세션마다 `wait-pane -m TASK-DONE`을 한 번 보내고 답을 기다려요. 2026-10-21, Mac mini M1에서 두 바퀴 번갈아 측정했어요. "감지 지연"은 줄이 출력된 순간부터 호스트가 알아챈 순간까지예요. | 방식 | 서버 CPU | 호스트(Node) CPU | 감지 지연 p50 / p95 | |---|---|---|---| | 500ms 폴링 | 2.0% | 0.6% | 253\~262 / 479\~489ms | | 200ms 폴링 | 2.9\~3.0% | 1.1\~1.3% | 86\~96 / 173\~196ms | | **`wait-pane`** | 3.8\~3.9% | **0.3\~0.4%** | **1 / 2ms** | - **알아채는 시간이 86\~262배 짧아요(p50 86\~262ms → 1ms).** "프롬프트가 뜨면 다음 입력 보내기"를 반복하는 자동화는 단계마다 그만큼 빨라져요. 호스트에서 폴링 코드도 사라져요. - **CPU는 공짜가 아니에요.** 서버 CPU가 200ms 폴링보다 0.8\~1.0%p 늘어요(세션 200개). 기다리는 세션은 출력을 300ms씩 모으지 않고 바로 읽기 때문이에요. 같은 세션을 모으기 없이 읽기만 해도 서버 CPU가 2.0%에서 2.8%로 늘었어요. 대기가 끝나면 원래대로 돌아가요. - **글자 검사는 이번 읽기가 바꾼 행만 해요.** 처음 만든 판은 읽을 때마다 화면 전체를 글자로 바꿔 검사해서 서버 CPU가 4.4\~6.3%였어요. 바뀐 행만 보게 해서 2.7\~3.2%가 됐고, 남은 비용은 바로 읽는 비용이에요. 화면 전환(대체 화면에서 나올 때)은 예전 행이 다시 보이므로 전체를 검사해요. - **출력을 모아 읽는 그대로 기다리게 해 봤더니** 서버 CPU는 폴링보다 15% 적었지만, 감지 지연이 p50 340ms로 500ms 폴링보다 늦었어요. 그래서 바로 읽는 방식을 택했어요. ## 여러 세션에서 글자 찾기: `grep-panes` (`bench/grep.zsh 1000`) 대기 중인 claude 화면 1000개 중 3개에 표시를 넣고, 그 3개를 찾는 데 걸린 시간이에요(2026-10-21, Mac mini M1, `bench/grep.zsh` 두 번, 한 번에 두 바퀴씩). | 방법 | 시간 | 서버 메모리 (전 1.41MB) | |---|---|---| | CLI로 세션마다 `capture-pane` | 4.2\~4.3초 | 1.70\~2.05MB | | zsh 함수로 세션마다 `capture-pane` | 0.88\~1.02초 | 1.71\~1.81MB | | Node(`client/zeropty.mjs`)로 세션마다 `capture-pane` | 92\~118ms | 2.11\~2.48MB | | **`grep-panes`** | **16\~32ms** | **1.60\~1.76MB** | - **터미널에서 "어느 세션이 입력을 기다리지?"가 약 4초에서 16\~32ms가 돼요.** - 방법마다 잰 메모리에는 앞 바퀴들이 펼쳐 둔 화면이 들어 있어요. `grep-panes`는 바퀴마다 맨 먼저 돌고, 첫 바퀴 값은 1.60\~1.63MB예요. - **화면을 녹이지 않아요.** `capture-pane`은 읽은 화면을 다시 얼 때까지 펼쳐 두지만(1000개면 +1MB 남짓), `grep-panes`는 언 화면의 사본을 읽어요. - tmux에는 화면 내용을 찾는 명령이 없어요(`find-window -C`는 대화형 선택 화면이에요). ## 10배 목표 현황 (전체) | 항목 | 상태 | |---|---| | CPU: 200·400개의 모든 부하 (화면 갱신, claude 기록 통째 출력, 실제 claude 작업) | ✅ 13.7\~17.9× | | 메모리: 화면 갱신, 실제 claude 작업 200·400개 (독립 서버) | ✅ 10.4\~147× | | 메모리: 임베디드 대기 400개, 실제 작업 200·400개 | ✅ 13.1\~19.9× (polled), 10.4\~14.4× (엔진 스레드, 대기 400개 10.8×) | | 메모리: 세션 하나당 증가분 | ✅ 대기·화면 갱신·실제 작업 14.1\~1207×. ⛔ claude 기록 통째 출력 7.9\~28.9× (두 회차가 352KB 차이, 독립 서버 절 참고) | | 응답 속도: 명령마다 tmux 실행 대비 | ✅ 소켓 31.0\~72.1×, 임베디드 54.7\~280× (회차별 57\~268× †) | | 응답 속도: tmux 제어 모드 대비, 임베디드 | ⛔ 키→화면 8.9\~10.8× (회차별 9\~10× †), send-keys 6.0\~8.1× (cooked 세션의 에코), capture 3.2\~10.0× (원인은 위 응답 속도 절). 범위의 아래 끝이 10배 미만이라 README에는 올리지 않아요 | | 응답 속도: tmux 제어 모드 대비, 소켓 | ⛔ 1.6\~2.8× (비동기, 회차별 1.7\~2.8× †), 3.0\~4.1× (동기 클라이언트, †), 프로세스 간 왕복 하한 | | 메모리 절대값: 독립 서버의 대기, claude 기록 통째 출력 | ⛔ 4.1\~6.9×, 서버 프로세스 하한 (빈 C 프로세스 0.87MB, 아래 메모리 절) (임베디드 polled로 넘음: 대기 12.8\~17.7×, 실제 작업 13.1\~19.9×. 서버 플러그인을 켜서 CLI·attach로 다뤄도 대기 200개 12.8\~13.1×) | | 메모리: 임베디드 대기 200개 | ✅ 12.8\~13.0× (polled, Node 기본값). 엔진 스레드 방식은 ⛔ 7.2\~7.5×, 스레드 비용 308\~309KB | | 대량 출력: 프로그램이 출력을 끝내기까지 | ✅ 36.0\~58.6× | | 대량 출력: 화면에 보이기까지 | ✅ 10.5\~12.5× (2026-10-18에는 경계 9.9\~11.7×. 이번엔 tmux가 느렸고 zeropty는 같았어요. 화면 시간에는 `capture-pane`을 프로세스로 띄우는 오차가 들어 있어요, 위 대량 출력 절) | | 응답 속도: 셸에서, zsh 함수 vs tmux CLI | ✅ 12.1\~56.2× | | 응답 속도: 셸에서, bash builtin vs tmux CLI | ✅ 48.7\~157× (프로그램끼리는 1.5\~1.7×, 프로세스 실행 비용 하한) | ## 측정 다시 하기 ```sh ./build.sh bash bench/suite.sh # 독립 서버 메모리 · CPU, 약 15분 clang -O2 -o /tmp/embed_host bench/embed_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist /tmp/embed_host 400 trace $PWD # 임베디드 메모리 (idle | trace) /tmp/embed_host 200 idle $PWD polled ~/.zeropty/eh.sock # 서버 플러그인을 켠 임베디드 메모리 clang -O2 -o /tmp/listen_host bench/listen_host.c -Ldist -lzeropty_c -Wl,-rpath,$PWD/dist /tmp/listen_host ~/.zeropty/lh.sock $PWD & # 서버 플러그인 응답 속도: latency_c ~/.zeropty/lh.sock <tmux -L 이름> 400 [간격 µs] node bench/latency.mjs 200 # 응답 속도 bench/bulk.sh zeropty; bench/bulk.sh tmux # 대량 출력 bench/shell.zsh # 셸에서 응답 속도 (zsh 함수, 프로그램, tmux) node bench/wait.mjs 200 # 폴링 대 wait-pane (CPU, 감지 지연) bench/grep.zsh 1000 # 세션 1000개에서 글자 찾기 (grep-panes 대 capture 반복) ``` 세션 1000개 측정은 사용자당 프로세스 한도(`kern.maxprocperuid`, 기본 2666)를 확인한 뒤 진행해요. `load.sh`가 미리 확인하고 넘으면 멈춰요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/changelog --> # Changelog 사용자에게 보이는 변경을 판마다 적어요. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/), 판 번호는 [유의적 버전](https://semver.org/lang/ko/)을 따르고, 무엇이 깨는 변경인지는 [버전 정책](/ko/guide/versioning)에 있어요. 릴리스 파일은 [GitHub Releases](https://github.com/CreatiCoding/zeropty/releases)에 있어요. ## [Unreleased] ## [0.1.6] - 2026-10-24 <Badge type="tip" text="최신" /> ### Added - Linux 지원(x86_64, glibc 2.29 이상, 커널 5.3 이상). 엔진은 kqueue 대신 epoll에서 기다리고 `zeropty::engine` 타입은 그대로예요. shim은 `LD_PRELOAD`(`shim.so`)로 걸려서, `zeropty-tty`가 동적 링크된 프로그램을 복사·서명 없이 그대로 돌려요. 릴리스마다 `zeropty-<version>-linux-x86_64.tar.gz`(`zeropty`, `shim.so`, `zeropty-tty`, `claude-zeropty`)도 붙고, 해시는 같은 `SHA256SUMS`에 있어요. 정적 링크된 프로그램(대부분의 Go 바이너리)은 shim을 불러오지 않아서 터미널을 못 봐요. ## [0.1.5] - 2026-10-21 ### Fixed - `zeropty-tty`로 띄운 job control 셸(zsh, `bash -i`)에서 작업에 친 `C-c`, `C-\`, `C-z`가 작업이 아니라 셸로 갔어요. 그래서 `sleep 30`을 멈출 수 없었어요. 작업은 자기 프로세스 그룹을 이끄는데, 셸의 `tcsetpgrp`를 shim이 삼켰기 때문이에요. 이제 shim이 이를 알리고, zeropty는 tty가 포그라운드 그룹에 하듯 그 그룹에 시그널을 보내요. 그래서 파이프라인을 포함해 `C-z`와 `fg`도 돼요. 세션 프로그램 아래에 멤버가 있는 프로세스 그룹만 받아들여요. 업그레이드 전에 띄운 셸은 예전 shim을 그대로 쓰니 다시 띄워야 해요. ## [0.1.4] - 2026-10-21 ### Fixed - `zeropty-tty`: `ssh`, `ssh-keygen`, `ssh-add` 등 `readpassphrase`나 `getpass`를 쓰는 프로그램에 친 암호가 세션 화면에 보였고, 그래서 `capture-pane` 결과와 스크롤백에도 남았어요. libc가 거기서 에코를 자기 내부 호출로 끄는데 shim이 그 호출에 닿지 못했기 때문이에요. 이제 세션 터미널에서는 shim이 이 두 함수를 직접 처리해요. `zeropty-tty ssh-keygen`처럼 shim이 들어간 프로그램에 해당하고, 세션 안 셸에서 친 `/usr/bin` 프로그램은 여전히 shim 없이 돌아요. 세션 밖에서는 바뀐 게 없어요. ## [0.1.3] - 2026-10-21 ### Added - 이제 릴리스마다 미리 빌드한 macOS 묶음 `zeropty-<버전>-darwin-universal.tar.gz`(Apple silicon·Intel, macOS 11 이상)와 `SHA256SUMS`가 붙어서, Rust 없이 설치할 수 있어요. 받고, `shasum -a 256 -c SHA256SUMS`로 확인하고, 풀어서 그 폴더에서 실행하면 돼요. `zeropty`, `shim.dylib`, `zeropty-tty`, `claude-zeropty`와 라이선스가 들어 있고 ad-hoc 서명돼 있어요. 임베딩 라이브러리와 bash builtin은 여전히 `./build.sh`로 만들어요([README](/ko/guide/install)). ## [0.1.2] - 2026-10-20 ### Fixed - `zeropty-tty`: `/dev/tty`를 읽기+쓰기로 연 프로그램(stdin을 파이프로 받은 `fzf`)은 키는 읽어도 쓰기가 모두 `EBADF`로 실패해서, fzf가 목록을 그리지 못하고 선택도 받지 못했어요. 이제 세션의 터미널은 PTY가 파일 하나인 것처럼 소켓 하나예요. stdin, stdout, stderr가 모두 그 소켓이고(stdin은 파이프였어요), 어느 모드로 연 `/dev/tty`도 그 소켓이에요. `C-d`는 여전히 입력을 아주 끝내고, 그 뒤에도 출력은 계속 나와요. PTY처럼 stdin, stdout, stderr 중 하나에 건 `O_NONBLOCK`은 이제 셋 모두에 걸려요. 예전 서버가 시작해서 `restart-server`로 이어진 세션은 입력 파이프를 그대로 써요. 이 버전이 시작한 세션을 0.1.1 이하로 되돌려 넘기지는 마세요(`C-d`가 닿지 않아요). ## [0.1.1] - 2026-10-20 ### Fixed - `zeropty-tty`: shim이 어느 프로그램이든 fd 0-2를 터미널로 쳐서, stdout을 파이프나 파일로 돌린 프로그램의 출력에 `\e]zeropty-tty;N\a` 모드 신호가 섞이고, stdin이 `/dev/null`인 프로그램이 실패하고(kqueue `EINVAL`), 자식 프로세스도 같은 영향을 받았어요. 이제 서버가 새 `ZEROPTY_TTY_ID` 변수로 알려 주는 세션 자신의 파이프와 소켓만 터미널로 치고, 모드 신호는 세션 출력으로만 보내요. - `zeropty-tty`: 서명한 사본이 링크 대상의 이름을 써서(`claude`가 `claude.exe`로 실행됨) `ps`, `pgrep claude`로 찾을 수 없었어요. 이제 사본이든 아니든 프로그램은 실행할 때 쓴 이름으로 돌아요. - `display-message`의 `-t`가 없는 세션을 가리키면 이제 tmux처럼 세션 변수를 비운 포맷을 출력하고 종료 코드 0이에요(전에는 종료 코드 1로 실패). 세션이 있는지 확인하려면 `has-session`을 쓰세요. 다른 명령은 tmux처럼 여전히 종료 코드 1로 실패해요. - 없는 `-t` 대상에 대한 오류 문구를 tmux와 같게 맞췄어요. `:`가 없을 때 이름만 쓰면 `can't find pane: x`(`capture-pane`, `send-keys`, `pipe-pane`, `paste-buffer`, `resize-pane`)나 `can't find window: x`(`resize-window`), id는 `$`·`@`·`%`에 따라 세션·창·pane 오류, `x.0`은 `can't find window: x`예요. `set-environment`는 `no such session: `과 받은 대상 그대로예요. 종료 코드는 그대로(1)예요. ## [0.1.0] - 2026-10-20 첫 공개 버전이에요. 터미널 프로그램을 많이 다루는 프로그램(코딩 에이전트, 스크립트, 테스트 하네스)을 위한 세션 엔진으로, tmux의 명령 형식을 PTY 없이 받아요. macOS 전용이에요. 첫 공개 커밋 `c612089`에 `v0.1.0` 태그를 달았어요. 알려진 한계: macOS만 돼요(kqueue, DYLD shim). 세션 하나에 프로그램 하나라서 창·패널·상태 줄·복사 모드·키 바인딩·설정 파일이 없어요. `wait-pane`은 임베디드 함수 호출로는 못 쓰고 소켓으로만 돼요(임베디드 `listen` 포함). SIP로 보호되거나 hardened runtime으로 서명된 프로그램은 shim을 못 읽어서 `zeropty-tty`가 다시 서명한 복사본을 실행하고, setuid·entitlement 프로그램(`top`, `ps`)은 안 돼요. `C-d`의 파일 끝은 세션 입력을 닫아요(파이프에는 한 번짜리 EOF가 없어요). 1.0 전에는 MINOR 릴리스가 docs/VERSIONING.md의 인터페이스를 깰 수 있어요. ### Added - **서버와 CLI**(`zeropty`), tmux와 같은 인자: `new-session`, `has-session`, `kill-session`, `list-sessions`, `kill-server`, `send-keys`(키 이름을 tmux 3과 바이트 단위로 대조), `capture-pane`(`-p -e -J -N -S -E -b -q`), `load-buffer`, `paste-buffer`, `display-message`, `pipe-pane`, `rename-session`, `resize-window`/`resize-pane`, `set-environment -g`/`show-environment -g`, `attach-session`(`-r` 읽기 전용), `-V`, tmux 포맷 변수 25개. 모르는 플래그는 오류예요. 첫 `new-session`이 서버를 띄워요. - tmux에 없는 명령: `wait-pane`(폴링 없이 글자나 조용해짐을 기다림), `grep-panes`(모든 세션 화면 검색), `pull-sessions`/`push-sessions`(프로그램을 건드리지 않고 실행 중인 세션을 다른 프로세스로 옮김), `restart-server`(세션을 끝내지 않고 서버 업그레이드). - **PTY 없음**: 세션의 프로그램은 파이프와 이야기하고, 줄 규칙(Enter, `C-c` 시그널, 줄 편집, 에코, `C-d`)은 엔진이 흉내 내요. 터미널이 필요한 프로그램(claude, vim, less, python)에는 `shim.dylib`과 `claude-zeropty`, `zeropty-tty`가 `isatty`, `tcgetattr`, 창 크기, `/dev/tty`에 답해요. - **화면 에뮬레이터**: 칸당 6바이트이고, 녹화한 claude 세션과 무작위 입력으로 `vt100` 크레이트(0.16)와 대조해요. 조용한 화면은 접어 두었다가 나중에 zstd로 압축해요. - **소켓 프로토콜**: `~/.zeropty/mux.sock`(`ZEROPTY_SOCK`)에서 요청과 응답 한 줄씩 JSON, 선택할 수 있는 raw 응답 형식, attach 프레임. - **임베드**: C ABI(`libzeropty_c.dylib`, `zeropty.h`: `zeropty_open`, `zeropty_command`, `zeropty_listen`, polled용 `zeropty_open_polled`/`zeropty_fd`/`zeropty_timeout_ms`/`zeropty_process`)와 Node 모듈(`zeropty.node`, `client/embedded.mjs`)로 엔진을 프로세스 안에서 돌려요. `listen`은 같은 소켓 프로토콜을 호스트 프로세스에서 제공해요. - **클라이언트**: `client/zeropty.mjs`(Node, 연결 하나를 유지), 프로세스를 띄우지 않고 소켓과 이야기하는 zsh 함수 (`shell/zeropty.zsh`)와 bash builtin(`shell/zeropty_bash.c`). - 환경변수 `ZEROPTY_SOCK`, `ZEROPTY_HISTORY`, `ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`, `ZEROPTY_COLD`, `ZEROPTY_COLD_DIR`, `ZEROPTY_RECORD`(docs/USAGE.md). - tmux 3.7c와 Mac mini M1 한 대에서 번갈아 잰 벤치마크(docs/BENCHMARKS.md): 잰 14개 항목 중 11개가 10배 이상이고, 3개는 아직이에요(독립 서버의 대기 400개 메모리 6.2배, tmux 제어 모드 대비 `send-keys` 임베디드 8~10배·소켓 2.2~2.8배). 이유는 그 문서에 적었어요. - 플러그인 시스템([플러그인 API](/ko/reference/plugin-api)): 세션 생성·종료·이름 변경·출력 이벤트를 받고 새 명령을 더해요. Rust `Plugin` 트레이트(`API_VERSION` 1), 소켓의 `plugin-attach`(언어 무관, 프로세스 밖), C 콜백 `zeropty_add_plugin`, dylib 플러그인 `zeropty plugin-run`/`zeropty_load_plugin`, 옵트인 자동 시작 디렉터리 `ZEROPTY_PLUGIN_DIR`, 상태 확인 `list-plugins`. 플러그인이 없으면 비용이 없고, 패닉·멈춤·느린 플러그인은 엔진을 붙잡지 못해요. - 기다리지 않는 명령 호출: C `zeropty_command_start`·`zeropty_call_poll`·`zeropty_call_due_ms`·`zeropty_call_free`, Node `Engine.commandStart`. - `zeropty --version`: `zeropty -V`와 같은 출력(`zeropty 0.1.0`)이에요. zsh 함수, bash builtin, `client/zeropty.mjs`도 `-V`처럼 프로그램을 실행해 답해요. - 포맷 변수 `#{version}`: 실행 중인 서버(또는 임베디드 엔진)의 버전이에요. 이 변수만 쓰는 `display-message -p`는 세션이 없어도 돼요(`zeropty display -p '#{version}'`). - C ABI: `zeropty.h`에 `ZEROPTY_VERSION_MAJOR`·`_MINOR`·`_PATCH`·`_PRERELEASE`·`_IS_PRERELEASE`·`_STRING`·`_NUMBER`, 링크된 라이브러리의 버전을 돌려주는 `zeropty_version()`. `ZEROPTY_VERSION_NUMBER`는 프리릴리스를 담지 않아서 `0.2.0-rc.1`과 `0.2.0`이 같은 값이에요. 둘은 `ZEROPTY_VERSION_IS_PRERELEASE`로 구별해요. - Node: `zeropty.node`와 `client/embedded.mjs`의 `version()`, `client/zeropty.mjs`와 `client/embedded.mjs`의 `VERSION`. - 버전 정책 `docs/VERSIONING.md`: 깨는 변경으로 보는 호환 범위(CLI의 인자·출력·종료 코드, 소켓 프로토콜, 세션 레코드, C ABI, Node API, 환경변수, `dist/` 이름)와 MSRV(Rust 1.88). 1.0 전에는 MINOR 릴리스가 이 범위를 깰 수 있고, 그때는 `**BREAKING**` 항목으로 알려요. - `zeropty --help`(`-h`, `help`): 명령과 옵션 목록을, `zeropty help <명령>`은 그 명령의 사용법과 문서 링크를 출력해요. 서버 없이 답해요. - 문서: 한국어 README 개편, 문서 목록(`docs/README.md`), FAQ, 실행되는 예제(`examples/`), 모든 공개 Rust 항목의 rustdoc과 예제. ### Fixed - 출력을 읽는 쪽이 파이프를 일찍 닫아도(`zeropty capture-pane -p ... | head -1`) CLI가 패닉하지 않아요. [Unreleased]: https://github.com/CreatiCoding/zeropty/compare/v0.1.6...HEAD [0.1.6]: https://github.com/CreatiCoding/zeropty/compare/v0.1.5...v0.1.6 [0.1.5]: https://github.com/CreatiCoding/zeropty/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/CreatiCoding/zeropty/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/CreatiCoding/zeropty/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/CreatiCoding/zeropty/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/CreatiCoding/zeropty/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/CreatiCoding/zeropty/releases/tag/v0.1.0 --- <!-- https://zeropty.pubdoc.dev/ko/guide/contributing --> # 기여 안내 zeropty에 관심 가져 주셔서 고마워요. 버그 제보, 문서 고치기, 측정 결과 공유, 코드 기여 모두 환영해요. ## 시작하기 전에 - **공개 저장소예요.** 커밋에 비밀값, 토큰, 개인 경로(`/Users/이름/...`)나 이름, 내부 호스트 이름, 확인하지 않은 실제 세션 녹화를 넣지 마세요. 녹화한 화면에는 녹화한 사람의 프롬프트와 폴더가 보여요. 커밋 전에 `git diff --cached`를 꼭 보세요. - **목표는 모든 측정 항목에서 tmux보다 10배.** "tmux보다 낫다"로는 끝이 아니에요. 목표를 쉬운 쪽으로 바꾸지 않아요. - **특정 사용자나 프로그램을 위한 코드는 넣지 않아요.** 프로그램들은 tmux를 쓰듯 zeropty를 써요. claude는 선택 사항이고, 테스트와 벤치마크는 `cat`, `printf` 같은 평범한 프로그램으로 돌아가야 해요. - 코드 규칙, 측정 방법, 10배 목표의 원문은 [AGENTS.md](https://github.com/CreatiCoding/zeropty/blob/main/AGENTS.md)에 있어요(에이전트와 사람에게 같은 규칙이에요). - 큰 변경은 먼저 [이슈](https://github.com/CreatiCoding/zeropty/issues)로 방향을 맞추면 좋아요. - 보안 문제는 이슈로 올리지 말고 [보안 정책](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/SECURITY.md)을 따라 주세요. - 모든 참여자는 [행동 강령](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/CODE_OF_CONDUCT.md)을 지켜요. ## 빌드와 테스트 macOS, Rust(stable, edition 2024. 지원하는 가장 낮은 버전(MSRV)은 1.88), Xcode Command Line Tools가 필요해요. 예제를 돌리려면 Node 18 이상이 있으면 좋아요. ```sh git clone https://github.com/CreatiCoding/zeropty && cd zeropty git config core.hooksPath .githooks # 커밋할 때 메시지를, push 전에 scripts/check.sh를 돌려요 (node 필요) ./build.sh # dist/ 에 zeropty, shim, C·Node 바인딩, bash builtin cargo test --release --workspace # vt100 대조, 서버, 임베디드, 세션 옮기기, 플러그인, 문서 예제 RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace # API 문서: 경고가 없어야 해요 ``` - `./build.sh`는 `dist/`의 바이너리를 지운 뒤 복사해요. macOS는 실행 중인 바이너리를 그 자리에서 덮어쓰면 그 프로세스를 죽여요. - 화면 해석기를 고쳤다면 무작위 입력을 더 많이 돌려요: `SEEDS=20000 cargo test --release random_streams` - 다른 세션이 쓰는 체크아웃을 건드리지 않도록 `git worktree`에서 작업하는 걸 권해요. - 테스트는 병렬로 돌고 세션 프로세스를 동시에 띄워요. 한 번만 통과하는 걸로 끝내지 말고, 타이밍에 기대는 코드(소켓, 프로세스 종료, 대기)를 바꿨다면 전체 테스트를 여러 번 돌려 보세요. 예를 들어 리스너를 닫아도 그 순간 다른 스레드가 띄운 자식 프로세스가 exec 전까지 fd 사본을 들고 있어서, 같은 경로의 소켓이 잠깐 "살아 있는 서버"처럼 연결을 받을 수 있어요. 같은 이유로, fd 한쪽을 닫은 뒤 다른 쪽에서 끝(EOF·EPIPE)을 바로 확인하는 테스트는 닫기와 확인을 `sys::fd::no_spawns()` 가드 안에서 해요. 그동안에는 프로세스가 뜨지 않아서 닫은 fd의 사본이 남지 않아요(`engine::commands`의 `keys_and_buffers_reach_the_program` 참고, 측정은 `no_spawns`의 주석에). ## 코드 둘러보기 | 파일 | 하는 일 | |---|---| | `src/term/` | 화면 해석기 (`vt100` crate 0.16과 같은 결과) | | `src/sys/` | OS 경계 (kqueue, fd, 프로세스, 시간, `tty`) | | `src/engine/` | 엔진: 세션과 모든 명령. 새 명령은 `commands.rs`의 `COMMANDS`에 한 줄과 핸들러 하나 | | `src/protocol.rs`, `src/server.rs` | 소켓 프로토콜(바이트)과 소켓 서버 | | `src/host.rs`, `src/embed.rs` | 호스트 루프 한 바퀴, 호스트 안의 엔진 (자기 스레드 또는 호스트 루프) | | `src/engine/handover.rs` | 프로세스 사이에 세션 옮기기 | | `src/plugin.rs`, `src/plugin/` | 플러그인([플러그인 API](/ko/reference/plugin-api)). 플러그인 작성자용 안정 API는 `api.rs`, 호스트용은 `Plugins`·`Call` | | `src/main.rs`, `src/cli/` | `zeropty` 프로그램 (독립 서버, CLI, attach, `plugin-run`, `--help`, `--version`) | | `shim/shim.c` | `isatty` 등을 가로채는 shim | | `bindings/c`, `bindings/node`, `client/` | C ABI, Node 모듈, Node 클라이언트 | | `tests/` | `tests/diff.rs`(vt100 대조), `tests/server.rs`, `tests/embed.rs`, `tests/handover.rs`, `tests/version.rs`(버전 번호가 모든 곳에서 같은지) | | `api/`, `scripts/` | 공개 API 기준선(`api/*.txt`), 릴리스·검사 스크립트([릴리스](/ko/guide/releasing)) | | `bench/` | 벤치마크 | 구조 그림은 [설계](/ko/guide/design#전체-구조)에 있어요. ## 변경을 만들 때 **정확성** - 화면은 `vt100` crate(0.16)와 같아야 해요. `cargo test`가 녹화한 claude 세션과 무작위 입력을 둘 다에 넣고, 읽는 사이사이에 얼리기·녹이기·압축을 무작위로 섞어서 대조해요. - 새 코드 경로를 넣었다면 일부러 한 번 망가뜨려(mutation) 테스트가 실패하는지 보세요. 실패하는 테스트가 없으면 테스트를 더해요. 확인한 뒤에는 되돌려요. - `capture-pane` 출력은 바꾸려는 의도가 없다면 같은 화면에 대해 바이트까지 같아야 해요(저장해 둔 결과와 비교). - 끝에서 끝까지: 할 수 있다면 `dist/claude-zeropty`로 실제 claude를 세션에 띄워 `send-keys`, `paste-buffer`, `capture-pane -e -S`, ONLCR(`echo` 줄이 0열에서 시작), `kill-session`을 확인해요. **호환성** - CLI 동작, 소켓 프로토콜, 세션 레코드, C ABI(`bindings/c/include/zeropty.h`), Node API, 환경변수, `dist/` 이름은 하위 호환을 지켜요(무엇이 호환 범위인지는 [버전 정책](/ko/guide/versioning)). 더하는 건 괜찮지만 바꾸거나 빼는 건 깨는 변경이라 이슈에서 먼저 이야기하고, 커밋에 깨는 변경 표시를 해요. - C ABI나 Node 내보내기를 바꿨다면 `./build.sh && node scripts/check-api.mjs --update`로 `api/*.txt`를 같은 커밋에서 갱신해요. - 버전 번호는 손으로 고치지 않아요. 릴리스할 때 `node scripts/release.mjs bump ...`가 모든 곳을 고쳐요([릴리스](/ko/guide/releasing)). - 인터페이스는 tmux의 명령 형식이에요. CLI, 소켓, 임베디드 라이브러리에서 똑같이 동작해야 해요. **코드 스타일** - 주석은 영어로, **왜** 그렇게 했는지, 함정이 무엇인지, 그 선택을 정당화한 측정값을 남겨요. 예: `// v2: 400 busy claude sessions took 3.5 MB against 4.5 MB with v3` - 코드는 모두 rustfmt 형식이에요(설정은 `rustfmt.toml`). 커밋 전에 `cargo fmt --all`을 돌리고, `scripts/check.sh`가 `cargo fmt --all --check`로 확인해요([릴리스](/ko/guide/releasing#포맷)). - `cargo clippy --workspace --all-targets -- -D warnings`가 통과해야 해요. 크레이트 전체로 허용한 린트와 이유는 `Cargo.toml`에 있고, 새 예외는 그 자리에 `#[allow(...)]`와 이유를 달아요. - 핫 패스(엔진의 tick·출력 처리, 화면 해석기, capture)에서는 동적 디스패치나 할당을 늘리지 마세요. 필요하면 제네릭·트레이트의 정적 디스패치를 써요. - 공개 항목에는 문서 주석(`///`)을 달아요. crate에 `#![warn(missing_docs)]`가 켜져 있어요. - 커밋 메시지는 영어로, [Conventional Commits](/ko/guide/contributing#커밋-메시지-conventional-commits) 형식으로 써요. ## 커밋 메시지: Conventional Commits [Conventional Commits 1.0.0](https://www.conventionalcommits.org/ko/v1.0.0/)을 따라요. 메시지는 영어로 써요. ``` <type>(<scope>)!: <무엇을 했는지, 명령형 소문자로> <왜 바꿨는지. 측정한 숫자가 있으면 그 숫자.> BREAKING CHANGE: <깨는 변경이면, 사용자가 무엇을 바꿔야 하는지> Refs: #123 ``` - 첫 줄은 100자 이하, 끝에 마침표 없이. 본문과는 빈 줄로 나눠요. - `!` 또는 `BREAKING CHANGE:` 꼬리말이 **깨는 변경**의 표시예요. 무엇이 깨는 변경인지는 [버전 정책](/ko/guide/versioning)에 있어요(CLI, 소켓 프로토콜, 세션 레코드, C ABI, Node API, 환경변수, 빌드 결과물). - 한 커밋에는 한 가지 일만. 리팩터링과 동작 변경은 커밋을 나눠요. | type | 언제 | CHANGELOG | 릴리스 단계 (1.0 이전 / 이후) | |---|---|---|---| | `feat` | 새 명령·플래그·함수·환경변수 | Added | PATCH / MINOR | | `fix` | 버그 수정 (`fix(security)`는 보안 수정) | Fixed (Security) | PATCH / PATCH | | `perf` | 더 빠르거나 작게, 동작은 같게 | Changed | PATCH / PATCH | | `revert` | 이전 커밋 되돌리기 | Changed | PATCH / PATCH | | `refactor` | 동작이 같은 코드 변경 | – | – | | `docs` | 문서만 | – | – | | `test` | 테스트만 | – | – | | `build` | `build.sh`, Cargo 설정, 의존성 | – | – | | `ci` | 검사 스크립트·훅 설정 (`scripts/check.sh`, `.githooks/`) | – | – | | `chore` | 그 밖에 사용자가 보지 않는 것 (`chore(release): X.Y.Z`) | – | – | | `style` | 포맷만 | – | – | | 아무 type + `!` | 깨는 변경 | Changed, `**BREAKING**` | MINOR / MAJOR | scope는 선택이고 소문자예요. 자주 쓰는 것: `cli`, `server`, `engine`, `term`, `embed`, `plugin`, `protocol`, `handover`, `c-abi`, `node`, `shim`, `shell`, `bench`, `release`, `ci`. 예 (가상의 변경이에요): ``` feat(cli): add --version as an alias of -V fix(term): keep the cursor column after a wide character at the margin perf(engine): read coalesced output in one kevent batch 400 replaying sessions: <before>% -> <after>% CPU (bench/suite.sh, interleaved with tmux, twice). refactor(c-abi)!: take options as a struct in zeropty_open BREAKING CHANGE: zeropty_open(const char *) is now zeropty_open(const zeropty_options *); pass NULL for defaults. ``` ## 변경 기록: CHANGELOG.md - [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식이에요. 사용자에게 보이는 변경(`feat`, `fix`, `perf`, `revert`, 깨는 변경)은 **같은 PR에서** `## [Unreleased]` 아래 알맞은 제목(Added, Changed, Deprecated, Removed, Fixed, Security)에 한 줄을 더해요. 제목은 커밋 type이 정해요: `feat` → Added, `fix` → Fixed(`fix(security)` → Security), `perf`·`revert` → Changed, 깨는 변경 → Changed나 Removed의 `**BREAKING**` 줄. `scripts/check.sh`가 git-cliff 초안과 같은 규칙으로 새 항목의 제목을 대조해요([릴리스](/ko/guide/releasing#반자동인-이유)). - 커밋 로그를 붙여 넣지 말고, 사용자가 알아야 할 것을 **영어로** 쓰고, 같은 항목을 한국어로 이 한국어 사본([Changelog](/ko/guide/changelog))에도 적어요. 초안은 `node scripts/release.mjs notes`가 (영어 커밋 메시지로) 만들어 줘요. - C ABI나 Node 내보내기를 바꿨다면 `./build.sh && node scripts/check-api.mjs --update`로 `api/*.txt`를 갱신해 같은 커밋에 넣어요. 줄이 사라지면 깨는 변경이에요. - 깨는 변경은 `- **BREAKING** ...`으로 시작하고, 무엇을 바꿔야 하는지 적어요. - 성능 수치는 [벤치마크](/ko/guide/benchmarks)에 측정된 값만 써요. - 사용자에게 보이지 않는 PR(`refactor`, `docs`, `test`, `ci` ...)은 적지 않아도 돼요. 아직 릴리스하지 않은 기능을 고친 커밋처럼 그 커밋만 적을 게 없으면 메시지 끝에 `Changelog: skip` 꼬리말을 달고(깨는 변경은 안 돼요), PR 전체가 그렇다면 PR 설명에 그렇게 적어요. 관리자가 `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh`로 확인해요. - 버전 번호와 `## [X.Y.Z]` 제목은 손대지 않아요. 릴리스할 때 `scripts/release.mjs`가 해요([릴리스](/ko/guide/releasing)). ## 성능을 바꾸는 변경 메모리, CPU, 응답 속도 숫자를 움직이는 변경은 같은 커밋에서 [벤치마크](/ko/guide/benchmarks)를 갱신해요(날짜, 커밋, 기기, 배수). - tmux와 zeropty는 **같은 부하에서 번갈아** 재요(`bench/latency.mjs`가 그렇게 해요). 하나씩 차례로 재면 이 기기에서 30% 넘게 흔들렸어요. - 서버가 효율 코어에서 돌 때가 많아서 한 번 잰 값은 ±10\~15% 흔들려요. 결론을 내기 전에 반복하고, 메모리는 200개와 400개 사이의 세션당 증가분을 보는 게 좋아요. - 먼저 사용자당 프로세스 한도(`sysctl kern.maxprocperuid`)를 확인해요. 남은 벤치마크 서버는 그 소켓에 `kill-server`로 끝내고 `ps -U $(id -u) | wc -l`로 확인해요. - 프로파일: `sample <pid> 20 -file out.txt`. `zeropty stats`의 `heap_bytes`(할당기에 요청한 바이트)를 footprint와 비교하고, `zeropty mem -t <세션>`으로 화면 하나를 나눠 봐요. - 가설은 측정으로 확인한 뒤에 고쳐요. "당연한" 원인 여러 개가 틀렸고, 프로파일이나 카운터가 진짜 원인을 보여 줬어요. - BENCHMARKS.md에는 10배에 못 미친 항목(⛔)과 아직 안 잰 항목을 표시해요. 배수를 반올림해 올리거나 나쁜 줄을 빼지 않아요. - README에는 tmux 대비 10배 이상으로 잰 항목만, 잰 범위 그대로 올려요. 10배 아래로 떨어진 항목은 README에서 빠져요. 재현 방법: [벤치마크: 측정 다시 하기](/ko/guide/benchmarks#측정-다시-하기) ## 문서 - README.md와 `docs/`의 본문은 영어로 써요. 한국어 번역은 [소개](/ko/guide/introduction)와 [docs/ko/](/ko/guide/introduction)에 있고(해요체), 문서가 바뀌면 같은 섹션을 함께 고쳐 영어 파일과 맞춰요. 코드, 명령, 식별자는 두 언어 모두 영어 그대로예요. - 문서를 고친 뒤 `node scripts/check-links.mjs`로 상대 링크와 `#앵커`가 모두 열리는지 확인해요(영어와 한국어 파일 모두). - 문서에 넣는 명령과 코드는 실제로 돌려 보고 넣어요. - 명령 레퍼런스는 [명령](/ko/reference/commands), 설계와 수치 근거는 [설계: 어떻게 10배를 만들었나](/ko/guide/design), 모든 측정값은 [벤치마크](/ko/guide/benchmarks)에 있어요. - README의 그림(로고, 벤치마크, 구조)은 GitHub 모바일 앱에서도 보이도록 `https://cdn.creco.dev/zeropty/`의 절대 주소로 불러와요. 원본은 `docs/assets/`에 있고, `node docs/assets/make.mjs` 뒤 `sh docs/assets/webp.sh`로 다시 만들어요. 같은 이름으로 덮어쓰면 CDN 파일과 README가 따로 바뀌어 그 사이 첫 페이지에 옛 그림(옛 수치)이 보여요. 그래서 다시 그린 그림은 `webp.sh`의 `rev`를 올려 새 이름(예: `bench-v3-light.webp` → `bench-v4-light.webp`)으로 만들고 README의 `<picture>` 주소도 같은 커밋에서 바꿔요. 관리자가 새 파일을 CDN에 올린 뒤 병합해요. 그래야 첫 페이지 그림의 수치가 표와 어긋나는 때가 없어요. `sh docs/assets/cdn-check.sh`가 README의 CDN 그림이 모두 200으로 열리는지 확인하고, 아직 안 올라간 파일 이름을 알려 줘요. `scripts/check.sh`의 마지막 단계도 같은 검사를 해요(`--quick`이면 빠져요). 그림을 바꾼 PR은 관리자가 CDN에 올린 뒤 이 검사가 통과하는 것을 보고 병합해요. - 터미널 데모는 `./build.sh` 뒤 `node docs/assets/demo.mjs`로 1분 시작을 실제로 돌려 녹화해요(Google Chrome과 `img2webp`가 필요해요). 만든 `demo-{light,dark}.webp`는 저장소에 커밋하지 않고 관리자가 CDN에 올린 뒤, 다른 그림처럼 절대 주소의 `<picture>`로 README에 넣어요. ## Pull Request 1. 브랜치를 만들어 작업해요(`main`에 바로 올리지 않아요, force-push 금지). 2. `scripts/check.sh`를 돌려 모두 통과하는지 확인해요. 저장소에 호스팅 CI는 없고, 이 스크립트가 main에 들어가기 전의 모든 검사예요(호스팅 작업은 릴리스 묶음을 만드는 `.github/workflows/release.yml` 하나뿐이고, 묶음에 들어가는 것을 바꾸는 pull request에서도 돌아요). 관리자도 병합 전에 같은 스크립트를 돌려요. `git config core.hooksPath .githooks`를 해 두면 `.githooks/pre-push`가 push할 때마다 이 스크립트를 돌려요(`ZEROPTY_PRE_PUSH=quick`이면 `--quick`). ```sh scripts/check.sh # 모든 검사 (몇 분 걸려요. 첫 MSRV 검사는 처음부터 컴파일해요) scripts/check.sh --quick # 예제, MSRV, rustdoc, CDN 그림, git-cliff 초안을 빼고 ``` 첫 단계부터 차례로 돌고, 실패하면 그 단계의 이름을 알려 주고 멈춰요. - **build:** `./build.sh`(워크스페이스 release 빌드, `dist/`). - **tests:** `ZEROPTY_REQUIRE_ALL=1 cargo test --release --workspace --locked`(doctest 포함). 도구(cc, node, python3)가 없어 건너뛰었을 테스트는 실패로 바뀌어서, 통과하면 모든 테스트가 돈 거예요. - **versions agree:** `node scripts/release.mjs check`. HEAD에 태그가 있으면 `--tag <태그>`로 태그와 버전도 대조해요. - **public API:** `node scripts/check-api.mjs`, 그리고 기준 브랜치가 있으면 `--base <기준>`(사라진 줄이 깨는 변경으로 표시됐는지). - **release tooling tests:** `node --test scripts/test/*.test.mjs`. - **version smoke tests:** `scripts/smoke-version.sh`(CLI·서버·C·Node 버전). - **clippy:** `cargo clippy --workspace --all-targets --locked -- -D warnings`. - **rustfmt:** `cargo fmt --all --check`. - **commit messages and changelog:** 기준 브랜치 뒤의 커밋이 모두 Conventional Commits인지, 사용자에게 보이는 커밋이 있으면 `[Unreleased]`에 그 type에 맞는 제목 아래 새 항목이 있는지(`node scripts/check-commits.mjs --changelog`). - 여기부터는 `--quick`이면 빠져요. **rustdoc:** `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace`(문서 없는 공개 항목, 깨진 문서 링크). **examples:** 셸·Node·C·Rust 예제 6개. **MSRV:** `rustup run 1.88.0 cargo check --workspace --all-targets --locked`(`rustup toolchain install 1.88.0`이 필요해요). **git-cliff draft:** `node scripts/release.mjs notes`가 초안을 만드는지. **README images:** `docs/assets/cdn-check.sh`. 기준 브랜치는 `$ZEROPTY_CHECK_BASE`, 없으면 지금 브랜치의 upstream, 그것도 없으면 `origin/main`이에요. 3. 예제는 각자 임시 소켓의 서버나 프로세스 안의 엔진을 쓰고 끝날 때 정리해서, 쓰고 있는 세션을 건드리지 않아요. 4. PR은 squash 병합을 기본으로 해요. 그래서 **PR 제목이 main의 커밋 메시지**가 되니 Conventional Commits로 써요. 관리자가 병합 전에 `node scripts/check-commits.mjs --message "<제목>"`으로 제목(squash 메시지)을 확인해요. 5. PR 템플릿의 체크리스트를 채워요. 성능에 영향이 있다면 측정 전후 숫자를 붙여요. 6. 리뷰에서 나온 이야기는 커밋을 더해 반영해요. 기여한 코드는 저장소와 같은 [MIT](https://github.com/CreatiCoding/zeropty/blob/main/LICENSE-MIT) 또는 [Apache-2.0](https://github.com/CreatiCoding/zeropty/blob/main/LICENSE-APACHE) 라이선스로 배포돼요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/design --> # 설계: 어떻게 10배를 만들었나 **한 줄 요약: 1000개 중 대부분은 조용하다는 점을 이용했어요.** 조용한 화면은 거의 메모리를 안 쓰게 접어 두고, 바쁜 화면은 일을 묶어서 적게 해요. 아래 수치는 각 기법을 넣고 뺄 때 잰 값이고, 코드 주석에도 같은 근거를 남겨 두었어요. ## 전체 구조 엔진 하나가 모든 세션을 가져요. 엔진을 누가 돌리느냐에 따라 독립 서버(`zeropty serve`)가 되기도 하고, 내 프로그램 안의 라이브러리가 되기도 해요. 어느 쪽이든 명령 형식은 tmux와 같아요. ```mermaid flowchart LR subgraph clients["명령을 보내는 쪽"] cli["zeropty CLI"] sh["zsh 함수 · bash builtin"] node["client/zeropty.mjs"] att["zeropty attach"] end subgraph host["엔진을 돌리는 프로세스: zeropty serve 또는 내 프로그램"] srv["server: JSON 줄 소켓"] eng["engine: 세션 · 화면 · 명령<br/>스레드 하나, kqueue 하나"] emb["embed: Node 모듈 · C ABI · Rust"] end subgraph sessions["세션 (PTY 없음)"] p1["프로그램 1"] p2["프로그램 2 ... 1000"] end clients -->|"Unix 소켓 (~/.zeropty/mux.sock)"| srv srv --> eng emb -->|"함수 호출 (Node send-keys p50 0.06~0.07ms)"| eng eng -->|"stdin: socketpair"| sessions sessions -->|"stdout+stderr: 같은 socketpair"| eng ``` - **세션**은 `/bin/sh -c 명령`으로 띄운 프로세스 그룹이에요. 터미널은 socketpair 하나예요. stdin, stdout, stderr가 모두 그 한쪽 끝이에요(tmux에서 PTY 하나인 것처럼). - **터미널이 필요한 프로그램**은 `zeropty-tty`가 `DYLD_INSERT_LIBRARIES`로 shim을 넣어서 소켓을 터미널로 보이게 해요. - **플러그인**은 세션 이벤트를 듣고 명령을 더해요. 소켓으로 붙는 별도 프로세스, `plugin-run`으로 띄운 C 라이브러리, 호스트 프로세스 안의 콜백 모두 같은 등록부를 거쳐요([플러그인 API](/ko/reference/plugin-api)). - **엔진**은 kqueue 하나로 모든 세션의 출력, 프로세스 종료, 타이머를 기다려요. 호스트가 자기 fd를 같은 kqueue에 올려 두면(`Engine::watch`, 태그에 `HOST_TAG`) 이벤트 루프도 하나로 끝나요. ## 1. PTY 대신 소켓 tmux는 창마다 PTY를 하나씩 열어서 macOS의 PTY 한도(기본 511개, 최대 999개)에 걸려요. zeropty는 세션을 socketpair로 연결해서 한도가 없어요. claude에는 `shim.dylib`을 넣어 터미널로 보이게 해요(`claude-zeropty` 래퍼). `isatty`, `tcgetattr`, `ioctl(TIOCGWINSZ)`, `tcsetpgrp`, `readpassphrase`, `getpass` 등을 가로채요. PTY가 해 주던 일(Enter를 줄바꿈으로, `C-c`를 시그널로, 줄 편집, 에코)은 엔진이 키를 보낼 때 흉내 내요. 같은 키를 tmux와 대조했어요([사용법: 라인 디시플린](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/USAGE.md#키-입력과-라인-디시플린)). 프로그램이 터미널 모드를 바꾸면 shim이 그 사실을 출력 안에 끼워 보내요. 그래서 엔진은 "지금부터" 키를 어떻게 다룰지 정확히 그 지점부터 알아요. ```mermaid sequenceDiagram participant H as 호스트 (send-keys) participant E as 엔진 participant S as shim.dylib (프로그램 안) participant P as 프로그램 (vim, claude) P->>S: isatty(1), ioctl(TIOCGWINSZ) S-->>P: "터미널이에요", 크기는 ZEROPTY_SIZE_FILE의 cols rows P->>S: tcsetattr(raw 모드) S->>E: 출력 안에 OSC zeropty-tty#59;플래그 (화면에는 안 보여요) H->>E: send-keys C-c alt cooked 모드 (새 세션의 기본값) E->>P: 프로세스 그룹에 SIGINT else raw 모드 (shim이 알려 준 뒤) E->>P: 0x03 바이트 그대로 end ``` 창 크기가 바뀌면(사람이 다른 크기의 터미널로 `attach`) 엔진이 크기 파일을 다시 쓰고 SIGWINCH를 보내요. shim은 물어볼 때마다 그 파일을 읽어요. ## 2. 작은 화면 구조 화면 해석기를 직접 만들고, `vt100` crate와 같은 결과를 내는지 녹화한 claude 세션과 무작위 입력 수천 세트로 대조해요. - 셀 하나가 6바이트예요(`vt100` crate는 32바이트). 셀 크기를 반으로 줄이자 부하 상태의 CPU도 반이 됐어요. 세션이 수백 개면 셀 쓰기가 곧 캐시 미스이기 때문이에요. - 행은 마지막으로 내용이 있는 칸까지만 셀을 가져요. 빈 공간은 비용이 0이에요. ```text Cell (6바이트, packed) ┌───────────────────────────────┬───────────────┐ │ ch: u32 │ attr: u16 │ │ 코드 포인트 + 넓은 글자 플래그 │ 팔레트 번호 │ │ (여러 코드 포인트면 intern id) │ (색·글꼴 묶음) │ └───────────────────────────────┴───────────────┘ ``` ## 3. 화면을 접어 두기 (메모리) 화면은 조용한 시간에 따라 점점 더 작은 형태로 바뀌고, 출력이 오거나 누가 읽으면 다시 펼쳐져요. ```mermaid stateDiagram-v2 direction LR state "펼친 행: 파싱 중" as live state "행 압축: 행마다 바이트" as packed state "얼림: 화면 통째로 약 1KB" as frozen state "깊이 얼림: zstd, 임시 파일" as deep [*] --> live live --> packed: 읽기가 끝나면 건드린 행을 바로 packed --> live: 다음 출력 packed --> frozen: 250ms 조용 frozen --> deep: 10초 조용 frozen --> packed: 출력 또는 capture-pane deep --> packed: 출력 또는 capture-pane ``` - **행 압축.** 읽기가 끝나면 그 읽기가 건드린 행을 바로 압축해요(ASCII 한 글자에 1바이트, 같은 칸 반복은 한 번에). 바쁜 화면도 지금 파싱 중인 행만 펼쳐 둬요. - **얼리기.** 250ms 조용하면 화면 전체를 바이트 덩어리로 얼려요. claude 화면 하나가 수십 KB에서 약 1KB가 돼요. 자주 출력하는 세션은 기다리는 시간을 최대 800ms까지 늘려, 얼렸다 녹이기를 반복하지 않게 해요(400개 기준 4초까지 늘릴 때 3.3MB, 800ms에서 2.1MB, CPU는 같음). - **깊이 얼리기.** 10초 넘게 조용하면 zstd로 한 번 더 압축해요(약 절반). 이 덩어리들은 이름 없는 임시 파일 하나에 모아 둬서 프로세스 메모리에 잡히지 않아요. 각자 따로 할당했을 때는 400개 기준 0.3MB 데이터에 0.84MB를 차지했어요. - **할당기 길들이기.** mimalloc v2를 쓰고 해제한 페이지를 바로 OS에 돌려줘요. 400개 바쁜 claude 세션 기준으로 v3는 4.5MB, v2는 3.5MB였어요. 돌려주지 않으면 대기 400개가 파싱 때의 1.3MB를 계속 쥐고 있었어요. 자주 다시 만드는 버퍼는 한 덩어리를 재사용해요(파편화로 세션당 8.6KB를 쓰던 게 데이터 2.6KB 수준으로 줄었어요). ## 4. 일을 묶어서 적게 (CPU) 출력 하나가 화면에 반영되기까지의 길이에요. 읽기를 미루되, 화면을 읽는 쪽은 절대 기다리지 않게 했어요. ```mermaid sequenceDiagram participant P as 세션의 프로그램 participant K as kqueue participant E as 엔진 participant C as capture-pane 호출자 P->>K: 출력 (socketpair에 씀) K->>E: 읽을 수 있음 (edge-triggered) Note over E: 300ms 경계까지 모으기<br/>그 사이 이 세션의 이벤트는 끔 alt 16KB 이상 쌓임 또는 사람이 attach 중 E->>E: 바로 읽기 end C->>E: capture-pane E->>E: 밀린 출력부터 읽고 파싱 E-->>C: 최신 화면 E->>E: 경계에서 한 번에 읽기 → 빠른 길 파싱 → 행 압축 ``` - **스레드 하나, kqueue 하나로 모든 세션을 처리해요.** 세션마다 스레드나 프로세스를 두지 않아요. - **읽기 모으기.** 출력이 오면 바로 읽지 않고 300ms 경계까지 모았다가 한 번에 읽어요. 같은 순간에 깨어난 세션들도 한 번의 깨어남으로 처리해요. 400개 claude 기준 100ms에서는 CPU 5.2%, 300ms에서는 3.7%였어요. 대신 `capture-pane`은 쌓인 출력을 먼저 읽고 답해서, 화면을 읽는 쪽에는 늦는 일이 없어요. 사람이 붙어 보고 있거나 파이프가 차면(16KB) 바로 읽어요. - **빠른 길.** 출력의 대부분인 ASCII 연속 구간, UTF-8 연속 구간(claude의 테두리, 한글), 완결된 CSI 시퀀스는 범용 파서를 거치지 않고 바로 화면에 써요. ASCII 빠른 길만으로 파싱 비용이 반이 됐어요. - **출력 통로는 파이프가 아니라 socketpair.** macOS 파이프는 약 250개가 커지면 나머지가 512바이트로 묶여서, 세션 1000개에서 출력의 41%가 프로그램 안에 묶여 있었어요. 세션마다 64KB 소켓 버퍼를 따로 주자 다 흘러요. - **대량 출력은 다 받고 나서 파싱.** 파이프가 차면 프로그램이 멈출 때까지 전부 먼저 읽어요. 5.4MB 출력이 끝나는 시간이 17ms에서 \~3ms가 됐어요. - **버려질 행은 압축하지 않기.** 스크롤되어 나가는 행은 쓰기가 끝날 때 한꺼번에 압축하고, 그 사이 스크롤백에서 밀려난 행은 압축 없이 버려요. 5.4MB 파싱이 16.5ms에서 약 9ms가 됐어요. ## 5. 프로세스를 띄우지 않기 (응답 속도) 명령 하나의 비용은 대부분 프로세스 시작(빈 C 프로그램도 1.3ms)이에요. | 명령을 보내는 길 | 명령 하나에 드는 일 | 명령 하나의 시간 | |---|---|---| | tmux CLI | 프로세스 시작 → 서버 접속 → 실행 → 프로세스 끝 | 6.04\~6.33ms | | zeropty zsh 함수 | 셸 안에서 소켓에 접속해 JSON 한 줄 (프로세스 없음) | 0.11\~0.17ms | | zeropty bash builtin | 셸 안에서 소켓에 접속해 JSON 한 줄 (프로세스 없음) | 0.041\~0.075ms | | zeropty 임베디드 | 함수 호출 | Node에서 0.06\~0.07ms (`send-keys` p50) | ```mermaid flowchart TB subgraph tmux["tmux CLI: 명령 하나에 6.04~6.33ms"] direction LR t1["fork + exec tmux"] --> t2["서버에 접속"] --> t3["실행"] --> t4["프로세스 끝"] end subgraph zsh["zeropty zsh 함수 · bash builtin: 0.041~0.17ms"] direction LR z1["셸 안에서 소켓에 JSON 한 줄"] --> z2["엔진"] end subgraph emb["zeropty 임베디드: 0.06~0.07ms (Node)"] direction LR e1["내 프로그램의 함수 호출"] --> e2["엔진 (같은 프로세스)"] end tmux ~~~ zsh ~~~ emb ``` - **소켓.** 한 연결로 여러 명령을 보내요(Node 클라이언트). - **임베디드.** 엔진을 내 프로세스 안에 넣으면 명령이 함수 호출이 돼요. Node에서 `send-keys` 한 번이 p50 0.06\~0.07ms, `capture-pane`이 0.06\~0.07ms(바쁜 세션 0.10\~0.17ms)예요. 이 시간의 대부분은 쉬던 Node 스레드가 깨는 시간(\~40\~50µs)이고, 쉬지 않고 연달아 부르면 한 번에 2.6µs예요([BENCHMARKS](/ko/guide/benchmarks)). 서버 프로세스가 없으니 빈 프로세스 하나의 메모리(0.865MB) 하한도 없어요. 엔진 스레드조차 없이 호스트의 이벤트 루프가 엔진을 돌리게 하면(polled) 대기 200개가 0.68\~0.69MB에서 0.39MB가 돼요. 읽을 게 없는 `capture-pane`, `send-keys` 같은 명령은 엔진 스레드를 깨우지 않고 부른 스레드에서 바로 처리해요. - **zsh 함수, bash builtin.** zsh는 기본 소켓 모듈로, bash는 `enable -f`로 넣는 C 함수로 셸 안에서 서버에 바로 말해요. tmux 명령 6.04\~6.33ms가 zsh 0.11\~0.17ms, bash 0.041\~0.075ms가 돼요(`has-session`·`send-keys`, zsh는 p50. 2000줄 `capture-pane`은 zsh 0.50\~0.55ms, bash 0.114\~0.138ms). ## 6. 측정으로만 결정 매 변경을 tmux와 번갈아 측정하고 프로파일로 원인을 확인한 뒤 넣었어요. "당연해 보이는" 원인 여러 개(커널 시간, 얼리기 반복, 할당기)가 틀렸고, 프로파일이나 카운터가 실제 원인을 보여 줬어요. 효과가 장부상으로만 있는 기법(힙을 파일에 두기: footprint는 줄지만 RAM에는 그대로이고 디스크 쓰기가 분당 10MB 생김)은 넣지 않았어요. 벤치마크 점수만 올리는 기법도 넣지 않았어요. "claude 기록 통째 출력" 부하는 같은 15KB를 2초마다 그대로 다시 보내는 합성 부하인데, 실제 claude 녹화(40초)를 300ms 읽기로 나누면 같은 출력이 반복되는 경우가 0%예요. 그래서 "화면이 안 바뀌는 다시 그리기 건너뛰기"는 이 부하의 CPU 배수(10.2\~11.9×)만 올리고 실제 사용에는 효과가 없어 넣지 않았어요. 측정 방법과 모든 수치: [벤치마크](/ko/guide/benchmarks) ## 코드 지도 | 파일 | 하는 일 | |---|---| | `src/term/` | 화면 해석기(6바이트 셀, 행 압축, 얼리기, 깊이 얼리기). `vt100` crate와 줄 단위로 같은 의미. I/O 없는 순수 코드 | | `src/sys/` | OS 경계: kqueue, fd, 프로세스, 시간, 붙는 사람의 터미널(`zeropty::tty`). libc 입출력은 여기에서만 불러요 | | `src/engine/` | 엔진: 세션, 모든 명령(`commands.rs`의 `COMMANDS` 표), `wait-pane`, attach, 세션 옮기기(`handover.rs`). OS와는 `Poller`·`Clock`·`Programs` 트레이트로만 닿아서 가짜로 단위 테스트해요 | | `src/protocol.rs` | 소켓 위의 바이트: 요청·답 줄, attach 프레임. 서버와 CLI가 같이 써요 | | `src/server.rs` | 소켓 서버: 클라이언트, `attach`, `wait-pane`, 세션 넘기기. 엔진을 빌려 써요 | | `src/host.rs` | 호스트 루프 한 바퀴(이벤트 → 읽기 → attach 출력·플러그인 → 필터 변경)의 순서. 모든 호스트가 같이 써요 | | `src/embed.rs` | 호스트 안의 엔진: 자기 스레드(`Embedded`) 또는 호스트 루프(`Polled`). 바인딩은 `Frontend` 트레이트로만 불러요 | | `src/plugin.rs`, `src/plugin/` | 플러그인([플러그인 API](/ko/reference/plugin-api)): 등록부(`Plugins`)는 서버 다음에 도는 `host::Service`예요. 엔진은 플러그인이 원한 종류의 세션 이벤트만 모아요. 플러그인이 없으면 명령마다 원자적 읽기 한 번 | | `src/main.rs`, `src/cli/` | `zeropty` 프로그램: 독립 서버, CLI, attach, `plugin-run`, `--help`, `--version` | | `shim/shim.c` | `isatty` 등을 가로채는 shim | | `bindings/c`, `bindings/node` | C ABI(`zeropty.h`)와 Node 모듈(N-API) | | `client/` | Node 클라이언트(서버용 `zeropty.mjs`, 임베디드용 `embedded.mjs`) | | `shell/` | zsh 함수, bash builtin | | `tests/` | `vt100` 대조(녹화 + 무작위), 서버·임베디드·세션 옮기기 | | `bench/` | 벤치마크 스크립트 | 층마다 무엇을 맡고 어떤 불변식을 지키는지는 각 모듈의 `//!` 문서(rustdoc)에 있어요. Rust API 문서: [문서 목록](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/README.md#rust-api-문서). --- <!-- https://zeropty.pubdoc.dev/ko/guide/embed --> # C와 Rust에 zeropty 내장하기 이 가이드를 마치면 서버 프로세스 없이 내 C 또는 [Rust](https://www.rust-lang.org) 프로그램 안에서 세션을 실행할 수 있어요. CLI와 같은 명령을 보내고, 엔진 스레드를 쓸지 내 루프를 쓸지 고르고, CLI가 내 세션에 닿도록 소켓을 열어요. ## 시작하기 전에 - Xcode Command Line Tools(`xcode-select --install`)가 있는 Mac이 필요해요. - **C:** 소스에서 빌드해야 해요. `./build.sh`가 `dist/`에 `libzeropty_c.dylib`와 `zeropty.h`를 넣어요. 릴리스 아카이브에는 들어 있지 않아요. - **Rust:** Rust 1.88 이상이 필요해요. crate가 crates.io에 없으므로 git 의존성으로 넣어요. `./build.sh`는 필요 없어요. - 터미널이 필요한 프로그램(claude, vim)을 띄우려면 `dist/zeropty-tty`도 필요해요. crate에는 들어 있지 않아요. 자세한 내용은 [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs)를 보세요. ## 1. 빌드에 zeropty 추가하기 ::: code-group ```sh [C (터미널)] git clone https://github.com/CreatiCoding/zeropty && cd zeropty ./build.sh # dist/libzeropty_c.dylib and dist/zeropty.h ``` ```toml [Rust (Cargo.toml)] [dependencies] zeropty = { git = "https://github.com/CreatiCoding/zeropty" } ``` ::: Rust에서 한 커밋에 머물려면 `rev = "COMMIT"`을 더하세요. `COMMIT`은 커밋 해시로 바꾸세요. ## 2. 엔진 시작하고 명령 보내기 **임베디드** 엔진은 CLI와 같은 명령을 받고 같은 `code`, `out`, `err`를 돌려줘요. C에서 요청과 답은 소켓 프로토콜의 JSON 줄이에요. Rust에서는 `argv`와 `Reply`예요. ::: code-group ```c [examples/c/hello.c] #include <stdio.h> #include <string.h> #include <unistd.h> #include "zeropty.h" /* Runs one command and prints its reply. */ static void run(zeropty *zp, const char *request) { char *reply = zeropty_command(zp, request); printf("%s\n -> %s\n", request, reply); zeropty_free(reply); } int main(void) { zeropty *zp = zeropty_open(NULL); /* or "{\"history\": 2000}" */ if (!zp) return 1; run(zp, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"c\",\"-x\",\"40\",\"-y\",\"4\",\"exec cat\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"send-keys\",\"-t\",\"=c:\",\"-l\",\"hi from C\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"send-keys\",\"-t\",\"=c:\",\"Enter\"],\"cwd\":\"/\"}"); usleep(100 * 1000); /* time for cat to print the line back */ run(zp, "{\"argv\":[\"capture-pane\",\"-p\",\"-t\",\"=c:\"],\"cwd\":\"/\"}"); run(zp, "{\"argv\":[\"kill-server\"],\"cwd\":\"/\"}"); zeropty_close(zp); return 0; } ``` ```rust [examples/embed.rs] use std::time::{Duration, Instant}; use zeropty::embed::Embedded; use zeropty::engine::{Options, Reply}; fn main() { let zp = Embedded::start(Options::default()); let run = |args: &[&str]| -> Reply { zp.command(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>(), "/") }; // A session is a program on pipes: `sh` here, any program works (`exec cat`, a coding agent...). let r = run(&["new-session", "-d", "-s", "demo", "-x", "60", "-y", "10", "sh"]); assert_eq!(r.code, 0, "{}", r.err); // Keys go through the engine's line discipline, as through a PTY: Enter ends the line. run(&["send-keys", "-t", "=demo:", "-l", "echo \"answer: $((6 * 7))\""]); run(&["send-keys", "-t", "=demo:", "Enter"]); // capture-pane reads the output pending first, so the screen is never stale. let start = Instant::now(); let screen = loop { let screen = run(&["capture-pane", "-p", "-t", "=demo:"]).out; if screen.contains("answer: 42") || start.elapsed() > Duration::from_secs(5) { break screen; } std::thread::sleep(Duration::from_millis(10)); }; print!("{}", screen.trim_end_matches('\n')); println!(); // Every session's name and size, in tmux's format language. print!("{}", run(&["list-sessions", "-F", "#{session_name} #{window_width}x#{window_height}"]).out); // kill-server ends the sessions; dropping `zp` stops the engine's thread. run(&["kill-server"]); } ``` ::: `zeropty_open`(Rust는 `Embedded::start`)은 엔진에 자기 **엔진 스레드**를 줘요. 명령은 어느 스레드에서든 불러도 돼요. 화면 읽기, 키 보내기, 조회는 부른 스레드에서 실행되고, 나머지는 모두 엔진 스레드로 넘어가요. C에서는 모든 답을 `free()`가 아니라 `zeropty_free`로 풀어야 해요. 함수 호출에는 `wait-pane`이 없으므로 이 예제들은 화면을 루프로 읽어요. polled 엔진에서 기다리는 방법은 3단계에서 보여 줘요. ## 3. 내 루프로 엔진 돌리기 **polled** 엔진은 자기 스레드가 없어요. 내 이벤트 루프가 엔진의 fd를 기다리다가, 읽을 수 있게 되거나 타임아웃이 지나면 `process`를 불러요. 모든 호출은 그 루프의 스레드에서 해야 해요. 대기 세션 400개에서 polled 모드는 메모리를 0.74MB에서 0.45~0.46MB로 줄였어요. ::: code-group ```c [app.c] zeropty *a = zeropty_open_polled(NULL); for (;;) { // host loop: call zeropty_process when the engine fd is readable or the timeout expires struct pollfd p = { zeropty_fd(a), POLLIN, 0 }; poll(&p, 1, (int)zeropty_timeout_ms(a)); zeropty_process(a); } ``` ```rust [examples/polled.rs] let mut zp = Polled::start(Options::default()); let r = zp.command(&argv(&["new-session", "-d", "-s", "job", "sh -c 'sleep 0.5; echo build done; exec cat'"]), "/"); assert_eq!(r.code, 0, "{}", r.err); // wait-pane -t job -m 'build done' -T 5000, without the command name. let wait = zp.engine().wait_start(&argv(&["-t", "=job", "-m", "build done", "-T", "5000"])).unwrap_or_else(|r| panic!("{}", r.err)); let reply = loop { if let Some(reply) = zp.engine().wait_check(&wait) { break reply; } let mut p = libc::pollfd { fd: zp.fd(), events: libc::POLLIN, revents: 0 }; unsafe { libc::poll(&mut p, 1, zp.timeout_ms().min(i32::MAX as u64) as i32) }; zp.process(); }; zp.engine().wait_end(&wait); ``` ::: Rust에서는 `Engine::wait_start`와 `wait_check`로 화면을 폴링하지 않고 루프 안에서 `wait-pane`을 쓸 수 있어요. 세션을 `exec cat`으로 끝내서 출력한 뒤에도 살아 있게 했어요. 마지막 출력 직후에 끝나는 세션은 그 출력이 일치하기 전에 끝난 것으로 보고될 수 있어요. ## 4. CLI용 소켓 열기 **서버 플러그인**은 `zeropty serve`와 같은 서버를 내 프로그램 안에서 돌려요. 그러면 CLI, `attach`, 셸 함수, Node 클라이언트가 내 세션에 닿고, 그 소켓에서 `wait-pane`도 동작해요. ::: code-group ```c [app.c] if (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* could not open it */ } ``` ```sh [터미널] ZEROPTY_SOCK=/tmp/myapp.sock zeropty capture-pane -p -t '=w:' ZEROPTY_SOCK=/tmp/myapp.sock zeropty attach -t w ``` ::: - 경로는 103바이트 이하여야 해요. 그 경로에서 이미 서버가 답하면 `zeropty_listen`은 -1을 돌려줘요. - 소켓에서 보낸 `kill-server`는 세션만 끝내요. 내 프로그램은 계속 실행돼요. - 세션은 여전히 내 프로그램과 함께 끝나요. 남기려면 끝나기 전에 독립 서버로 `push-sessions` 하세요([세션 옮기기](/ko/guide/moving)). ## 확인하기 zeropty 폴더에서 각 예제를 빌드하고 실행하세요(3단계의 Rust 발췌는 `examples/polled.rs`를 줄인 거예요). ::: code-group ```sh [C] cc examples/c/hello.c -Idist -Ldist -lzeropty_c -o /tmp/zeropty-hello DYLD_LIBRARY_PATH=dist /tmp/zeropty-hello ``` ```sh [Rust] cargo run --release --example embed cargo run --release --example polled ``` ::: C 프로그램은 요청마다 그 답을 이어서 출력해요. 처음 세 개는 `{"code":0,"out":"","err":""}`가 보여야 하고, `capture-pane`의 답에는 `hi from C`가 두 번 들어 있어요. 한 번은 친 대로 에코된 것이고, 한 번은 `cat`이 되돌려 출력한 거예요. `embed`는 친 줄, 답, 세션 목록을 출력해요. ```text echo "answer: $((6 * 7))" answer: 42 demo 60x10 ``` `polled`는 대기 결과(`NNN`은 밀리초)와 화면을 출력해요. ```text wait-pane: code 0 after NNN ms build done ``` ## 문제가 생기면 | 증상 | 해결 | |---|---| | `Library not loaded: … libzeropty_c.dylib` | `DYLD_LIBRARY_PATH=dist`를 지정하거나, `-Wl,-rpath,"$PWD/dist"`로 링크하세요. | | `zstd-sys` 빌드가 실패해요 | Xcode Command Line Tools를 설치하세요. | | 프로그램이 끝나면 세션이 사라져요 | 세션은 내 프로세스 안에 있어요. 먼저 서버로 `push-sessions` 하세요. | | polled 엔진에서 다른 스레드의 호출이 이상하게 동작해요 | 모든 호출은 루프의 스레드에서 해야 해요. 여러 스레드에서 부르려면 `zeropty_open`을 쓰세요. | | 플러그인 명령이 내 루프를 잡고 있어요 | `zeropty_command_start`로 시작하고 `zeropty_call_poll`로 받으세요. [플러그인 작성하기](/ko/guide/plugins)를 보세요. | ## 다음 단계 - C ABI 의 모든 함수, 타입, 매크로는 [C ABI](/ko/reference/c-abi)를 보세요. - Rust 크레이트와 rustdoc 만드는 법은 [Rust API](/ko/reference/rust-api)를 보세요. - 임베딩을 소켓·CLI와 비교하려면 [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces)을 보세요. - 임베디드 엔진에 명령을 더하려면 [플러그인 작성하기](/ko/guide/plugins)를 보세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/faq --> # 자주 묻는 질문 **무엇인가요** - [tmux를 대신하나요?](/ko/guide/faq#tmux를-대신하나요) - [claude가 없어도 쓸 수 있나요?](/ko/guide/faq#claude가-없어도-쓸-수-있나요) - [Linux나 Windows에서도 되나요?](/ko/guide/faq#linux나-windows에서도-되나요) - [왜 README에는 10배 이상인 항목만 있나요?](/ko/guide/faq#왜-readme에는-10배-이상인-항목만-있나요) **쓰는 법** - [도움말은 어떻게 보나요?](/ko/guide/faq#도움말은-어떻게-보나요) - [`cargo install`로 설치하면 무엇이 빠지나요?](/ko/guide/faq#cargo-install로-설치하면-무엇이-빠지나요) - [tmux 스크립트를 그대로 쓸 수 있나요?](/ko/guide/faq#tmux-스크립트를-그대로-쓸-수-있나요) - [서버 프로세스 없이 쓸 수 있나요?](/ko/guide/faq#서버-프로세스-없이-쓸-수-있나요) - [에이전트가 끝났는지 어떻게 알아요?](/ko/guide/faq#에이전트가-끝났는지-어떻게-알아요) - [`capture-pane`이 300ms 늦은 화면을 주지 않나요?](/ko/guide/faq#capture-pane이-300ms-늦은-화면을-주지-않나요) - [세션을 잃지 않고 업그레이드할 수 있나요?](/ko/guide/faq#세션을-잃지-않고-업그레이드할-수-있나요) - [내 앱을 재시작해도 세션을 유지할 수 있나요?](/ko/guide/faq#내-앱을-재시작해도-세션을-유지할-수-있나요) **문제 해결** - [프로그램이 "터미널이 아니다"라며 다르게 동작해요](/ko/guide/faq#프로그램이-터미널이-아니다라며-다르게-동작해요) - [`wait-pane -m`이 바로 끝나요](/ko/guide/faq#wait-pane--m이-바로-끝나요) - [`wait-pane`이 글자가 나왔는데도 `session ended`로 끝나요](/ko/guide/faq#wait-pane이-글자가-나왔는데도-session-ended로-끝나요) - [`C-d`를 보낸 뒤로 입력이 안 들어가요](/ko/guide/faq#c-d를-보낸-뒤로-입력이-안-들어가요) - [zsh에서 `-t =work:`가 이상하게 동작해요](/ko/guide/faq#zsh에서--t-work가-이상하게-동작해요) - [`no server running on ...`이 나와요](/ko/guide/faq#no-server-running-on-이-나와요) - [세션을 많이 띄우면 실패해요](/ko/guide/faq#세션을-많이-띄우면-실패해요) - [서버를 끄려면요?](/ko/guide/faq#서버를-끄려면요) --- ## tmux를 대신하나요? **사람이 쓰는 터미널 멀티플렉서로서는 아니에요.** zeropty는 프로그램(특히 코딩 에이전트)을 띄워 두고, 화면을 읽고, 키를 보내고, 끝날 때까지 기다리는 일에 집중해요. 창 분할, 상태 표시줄, 복사 모드, 설정 파일은 없어요. 대신 그 일에서는 tmux와 같은 명령으로, 측정한 15개 항목 중 11개에서 메모리·CPU·응답 속도가 범위 전체로 10배 이상 좋고(4개는 10배 미만)([벤치마크](/ko/guide/benchmarks)), 세션 수에 PTY 한도가 없어요. 사람이 들여다보고 싶을 때는 `zeropty attach`로 붙어요. ## claude가 없어도 쓸 수 있나요? 네. 세션은 아무 프로그램이나 돼요(`sh`, `cat`, `python3`, 빌드 명령...). 테스트와 [예제](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/README.md#예제)도 모두 claude 없이 돌아가요. `claude-zeropty`는 claude를 터미널 없이 띄우기 위한 작은 래퍼일 뿐이에요. ## Linux나 Windows에서도 되나요? macOS와 Linux(x86_64, glibc 2.29·커널 5.3 이상)에서 돼요. Windows는 안 돼요. 엔진의 큐는 macOS에서 kqueue, Linux에서 epoll이고 같은 타입 뒤에 있어요(`src/sys/kq.rs`, `src/sys/epoll.rs`). 터미널이 필요한 프로그램을 속이는 shim은 macOS에서 `DYLD_INSERT_LIBRARIES`, Linux에서 `LD_PRELOAD`(`shim.so`)로 걸려요. Linux에서 정적 링크된 프로그램은 shim을 불러오지 않아서 터미널을 못 봐요. ## 왜 README에는 10배 이상인 항목만 있나요? 프로젝트의 목표가 "모든 항목에서 tmux보다 10배"이고, README는 그 목표에 도달한 항목만 보여 주기로 했어요. 10배에 못 미친 항목도 숨기지 않아요. [벤치마크](/ko/guide/benchmarks)에 ⛔ 표시와 함께 모든 측정값, 하한의 이유가 있어요. 숫자는 반올림하지 않고 잰 범위 그대로 적어요. ## 도움말은 어떻게 보나요? `zeropty --help`가 모든 명령과 옵션을 한 화면에 보여 줘요(`-h`, `help`도 같아요). 명령 하나만 보려면 `zeropty help send-keys`처럼 써요. 서버가 없어도 되고, 서버를 띄우지도 않아요. 옵션마다의 자세한 설명과 키 이름은 [사용법](/ko/reference/commands#명령-레퍼런스)에 있어요. tmux와 같은 이름과 옵션을 쓰니 tmux 문서도 대부분 그대로 통해요([tmux와 기능 비교](/ko/reference/tmux-compatibility#tmux와-기능-비교)). ## `cargo install`로 설치하면 무엇이 빠지나요? `cargo install --git https://github.com/CreatiCoding/zeropty`는 Rust 바이너리 `zeropty`(서버와 CLI) 하나만 `~/.cargo/bin`에 넣어요. 이것만으로 서버가 뜨고, `sh`·`python3`·빌드 명령처럼 터미널이 없어도 되는 프로그램을 세션으로 돌리고, `send-keys`·`wait-pane`·`capture-pane`·`attach`를 모두 쓸 수 있어요. 빠지는 건 이래요. `cargo install`은 패키지의 실행 파일만 설치하고, 아래 파일은 `./build.sh`가 따로 만들거나 복사하기 때문이에요. | 빠지는 것 | 왜 빠지나요 | 없으면 | |---|---|---| | `shim.dylib` | C 파일(`shim/shim.c`)을 `clang`으로 따로 빌드해요 | 터미널이 필요한 프로그램(claude, vim)이 "터미널이 아니다"라고 판단해요 | | `zeropty-tty`, `claude-zeropty` | 셸 스크립트이고, 자기와 같은 폴더의 `shim.dylib`을 찾아요 | claude를 터미널 없이 띄울 수 없어요 | | `libzeropty_c.dylib`, `zeropty.h`, `zeropty.node` | 워크스페이스의 다른 crate(`bindings/`)가 만드는 라이브러리예요 | C·Node 임베딩을 못 해요(Rust 임베딩은 git 의존성으로 돼요) | | `zeropty_bash.dylib` | C 파일(`shell/zeropty_bash.c`)을 따로 빌드해요 | bash builtin이 없어요(일반 CLI는 그대로 돼요) | 이런 것이 필요하면 [1분 시작](/ko/guide/getting-started)처럼 `git clone` 뒤 `./build.sh`로 빌드하고 `dist/`를 써요. ## tmux 스크립트를 그대로 쓸 수 있나요? 대부분은 `tmux`를 `zeropty`로 바꾸면 돼요. `new-session`, `send-keys`, `capture-pane`, `list-sessions -F`, `display-message -p`, `load-buffer`, `paste-buffer`, `pipe-pane`처럼 프로그램을 다루는 명령은 인자가 같아요. 다른 점은 이래요. - 창과 창 분할이 없어요. 세션 하나에 프로그램 하나예요(`split-window`, `new-window`는 없어요). - 타깃 이름은 정확히 일치해야 해요(tmux의 앞부분 일치 없음). - 포맷의 조건식(`#{?...}`)과 수식어(`#{t:...}`)는 안 돼요. - 모르는 옵션은 무시하지 않고 오류예요. 전체 비교: [사용법: tmux와 기능 비교](/ko/reference/tmux-compatibility#tmux와-기능-비교) ## 서버 프로세스 없이 쓸 수 있나요? 네. 엔진을 내 프로세스 안에 넣으면 돼요(Node 모듈, C ABI, Rust crate). 명령이 함수 호출이 돼서 Node에서 `send-keys` 한 번이 0.06\~0.07ms예요(명령마다 tmux를 실행하면 9.03\~9.56ms). 필요하면 내 프로그램이 소켓도 열어서(`listen`) CLI와 `attach`로도 접근할 수 있어요. [사용법: 임베디드](/ko/reference/rust-api#내-프로세스-안에-임베디드) ## 에이전트가 끝났는지 어떻게 알아요? `wait-pane`을 써요. 화면에 글자가 나타날 때(`-m`), 출력이 일정 시간 멈출 때(`-q`), 또는 둘 다일 때 답해요. 서버가 출력이 올 때만, 바뀐 행만 확인해서 폴링이 없어요. ```sh zeropty wait-pane -t '=fix-1:' -q 5000 -T 600000 # 출력이 5초 멈출 때까지, 최대 10분 ``` 여러 세션 중 어디가 입력을 기다리는지는 `grep-panes`로 한 번에 찾아요. ## `capture-pane`이 300ms 늦은 화면을 주지 않나요? 아니요. 엔진은 CPU를 아끼려고 출력을 300ms씩 모아 읽지만, `capture-pane`(그리고 `grep-panes`, `wait-pane`)은 밀린 출력을 먼저 읽고 답해요. 읽는 쪽은 늘 최신 화면을 받아요. 사람이 `attach`로 보고 있을 때도 출력이 바로 가요. ## 세션을 잃지 않고 업그레이드할 수 있나요? 네. 새 바이너리로 `zeropty restart-server`를 실행하면, 새 서버가 실행 중인 세션을 전부 넘겨받고 소켓 자리를 차지해요. 프로그램은 멈추지 않고 pid도 같아요. [사용법: 세션 옮기기](/ko/reference/socket-protocol#세션-옮기기) ## 내 앱을 재시작해도 세션을 유지할 수 있나요? 임베디드 엔진의 세션은 앱과 같이 끝나요. 앱이 끝나기 전에 `push-sessions -s <서버 소켓>`으로 독립 서버에 넘기고, 다시 시작할 때 `pull-sessions -s <서버 소켓>`으로 가져오면 돼요. ## 프로그램이 "터미널이 아니다"라며 다르게 동작해요 세션의 터미널은 PTY가 아니라 소켓이라서 `isatty()`가 거짓이에요. `zeropty-tty`로 띄우세요. ```sh zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt" ``` `/usr/bin`의 시스템 프로그램은 서명만 바꾼 사본으로 실행해요. `top`, `ps`처럼 특별한 권한이 필요한 프로그램은 안 돼요. [사용법: zeropty-tty](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/USAGE.md#터미널이-필요한-프로그램-zeropty-tty) ## `wait-pane -m`이 바로 끝나요 입력한 글자는 PTY처럼 화면에 에코돼요. `send-keys -l 'echo done'`을 보내고 `wait-pane -m done`을 하면, 입력한 줄의 `done`에 바로 맞아요. 입력한 줄에는 없고 결과에만 나오는 글자를 기다리세요. 예를 들어 `echo "exit: $?"`를 보내고 `-m 'exit: 0'`을 기다리면 돼요. ## `wait-pane`이 글자가 나왔는데도 `session ended`로 끝나요 세션이 기다리는 글자를 출력하자마자 끝나면, 엔진이 그 출력을 확인하기 전에 세션 종료를 먼저 알릴 때가 있어요(알려진 문제예요. 20번에 5번쯤 재현돼요). 그러면 `wait-pane: session ended`와 종료 코드 1이 나와요. 기다리는 글자가 나온 뒤에도 세션이 살아 있게 하면 돼요. ```sh zeropty new-session -d -s build "sh -c 'sleep 1; echo build done; exec cat'" # 끝에 exec cat zeropty wait-pane -t build -m 'build done' -T 10000 zeropty kill-session -t build ``` 셸(`sh`, `zsh`)이나 claude처럼 계속 도는 프로그램을 세션으로 띄우고 `send-keys`로 일을 시키는 흐름([1분 시작](/ko/guide/getting-started))은 이 문제가 없어요. ## `C-d`를 보낸 뒤로 입력이 안 들어가요 세션의 소켓에는 PTY처럼 "EOF를 한 번만" 알리는 방법이 없어서, 빈 줄의 `C-d`는 입력을 닫아요. 그 뒤로는 그 세션에 입력을 보낼 수 없어요. 프로그램을 끝내려는 게 아니라면 `C-d` 대신 `C-c`나 프로그램의 종료 명령을 보내세요. ## zsh에서 `-t =work:`가 이상하게 동작해요 zsh는 `=단어`를 명령 경로로 바꿔요(`=` 확장). `'=work:'`처럼 따옴표로 감싸세요. tmux도 같아요. ## `no server running on ...`이 나와요 그 소켓에 서버가 없어요. 서버는 첫 `new-session`이 띄워요. 다른 소켓을 쓰는 서버(예: 임베디드 앱의 `listen`)에 말하려면 `ZEROPTY_SOCK=<소켓> zeropty ...`처럼 소켓을 알려 주세요. ## 세션을 많이 띄우면 실패해요 PTY 한도는 없지만 macOS의 사용자당 프로세스 수 한도는 있어요. claude 세션 하나는 프로세스 4개쯤이에요. ```sh sysctl kern.maxprocperuid # 사용자당 프로세스 한도 ps -U $(id -u) | wc -l # 지금 내 프로세스 수 ``` 남아 있는 테스트·벤치마크 서버가 한도를 다 쓰고 있을 때가 많아요. 그 소켓에 `kill-server`를 보내 정리하세요. ## 서버를 끄려면요? ```sh zeropty kill-server ``` 모든 세션의 프로세스 그룹에 SIGHUP을 보내고 서버가 끝나요. 다른 소켓의 서버라면 `ZEROPTY_SOCK`을 함께 주세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/from-tmux --> # tmux에서 넘어오기 이 가이드를 마치면 기존 [tmux](https://github.com/tmux/tmux) 스크립트를 zeropty에서 실행하고, 폴링 루프를 `wait-pane`으로 바꾸고, 스크립트가 다르게 동작하게 만드는 몇 가지 차이를 알게 돼요. ## 시작하기 전에 - `PATH`에 zeropty가 있는 Mac이 필요해요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - tmux로 프로그램을 다루는 셸 스크립트가 필요해요. `new-session`, `send-keys`, `capture-pane` 같은 명령을 쓰는 스크립트예요. - 스크립트가 터미널이 필요한 프로그램(claude, vim)을 시작한다면 릴리스 아카이브나 `./build.sh`의 `zeropty-tty`가 필요해요. ## 1. 일대일로 대응하는 것 확인하기 zeropty는 프로그램을 다루는 데 필요한 tmux의 명령 이름, 별칭, 인자를 그대로 받아요. 포맷 변수는 같은 세션에서 tmux와 대조했고, 키 바이트는 tmux 3과 대조했어요. | 똑같이 동작하는 것 | 참고 | |---|---| | `new-session`, `kill-session`, `has-session`, `list-sessions`, `rename-session` | `new-session`은 `-n -E -D -X`를 받고 무시해요 | | `send-keys`, `paste-buffer`, `load-buffer` | 같은 [키 이름](/ko/reference/keys-and-formats#키-이름) | | `capture-pane` | `-p -e -J -N -S -E -b -q`만 돼요. 다른 옵션은 오류예요 | | `display-message -p`, `list-sessions -F` | [포맷 변수](/ko/reference/keys-and-formats#포맷-변수) 25개 | | `pipe-pane`, `resize-window`, `set-environment -g` | `pipe-pane -I`는 없어요 | | `attach` | `Ctrl-b d`로 떼요. `-r`은 읽기 전용이에요 | ## 2. 없는 것 확인하기 zeropty는 프로그램을 계속 실행하고, 화면을 읽고, 키를 보내는 일을 해요. 사람이 터미널을 나눠 쓰는 기능은 없어요. - **창(window)과 창 분할(pane)이 없어요.** 세션 하나가 프로그램 하나를 실행해요. `split-window`나 `new-window`는 없어요. `window_index`와 `pane_index`는 늘 `0`이에요. - **상태 표시줄, 복사 모드, 단축키 설정, 설정 파일, 훅이 없어요.** - **포맷 조건식과 수식어가 없어요**(`#{?...}`, `#{t:...}`). 모르는 변수는 tmux처럼 빈 문자열이 돼요. - **제어 모드(`-C`)가 없어요.** 프로그램은 대신 JSON 소켓 프로토콜이나 Node 클라이언트를 써요. 전체 표는 [tmux 호환](/ko/reference/tmux-compatibility)를 보세요. ## 3. 스크립트 바꾸기 `tmux`를 `zeropty`로 바꾸세요. 변수 한 줄을 쓰면 변경이 작아져요. ```sh [agents.sh] #!/bin/sh set -e T=zeropty # was: T=tmux $T new-session -d -s fix-1 -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" $T send-keys -t '=fix-1:' -l 'Fix the failing test' $T send-keys -t '=fix-1:' Enter ``` `~/proj`는 내 저장소로 바꾸세요. 터미널이 필요한 프로그램은 `zeropty-tty`로 시작해야 하고, claude는 `claude-zeropty`가 그 일을 해요. 자세한 내용은 [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs)를 보세요. 첫 `new-session`은 tmux처럼, 서버가 없으면 서버를 띄워요. ## 4. 폴링을 wait-pane으로 바꾸기 tmux 스크립트는 `capture-pane`을 루프로 불러서 에이전트가 끝났는지 알아요. zeropty는 일이 일어나는 순간에 답해요. ::: code-group ```sh [이전 (tmux)] until tmux capture-pane -p -t '=fix-1:' | grep -q 'All tests pass'; do sleep 0.2 done ``` ```sh [이후 (zeropty)] zeropty wait-pane -t '=fix-1:' -m 'All tests pass' -T 600000 ``` ::: `-m`은 글자를 기다리고, `-q`는 출력이 멈출 때까지 기다리고, `-T`는 타임아웃을 밀리초로 정해요. 서버는 출력이 올 때만 확인해요(감지 p50 1ms, p95 2ms). 여러 세션 중 어느 것이 어떤 글자를 보여 주는지 알려면 세션마다 `capture-pane`을 하지 말고 `grep-panes`를 한 번 쓰세요. 자세한 내용은 [화면, 키, 기다리기](/ko/guide/concepts/screens)를 보세요. ## 5. 스크립트를 깨뜨리는 차이 고치기 | 차이 | 바꿀 것 | |---|---| | **타깃은 정확히 일치해야 해요.** 앞부분 일치가 없어서 `-t fix`는 `fix-1`을 찾지 못해요. | 전체 이름을 쓰세요. 타깃은 `'=name:'`으로 쓰세요. | | **모르는 옵션은 오류예요**(`send-keys: unknown flag -X`). 무시하지 않아요. | zeropty가 목록에 두지 않은 옵션은 지우세요. 예전에는 조용히 무시해서 결과가 달라졌어요. | | **서버는 자신을 띄운 프로세스의 환경을 가져요.** | 변수는 `new-session -e VAR=value`로 넘기거나, 모든 새 세션에는 `set-environment -g VAR value`를 쓰세요. | | **zsh에서 `=name:`**은 명령 경로로 바뀌어요(`=` 확장). | 따옴표로 감싸세요: `'=fix-1:'`. tmux도 같아요. | | **`C-d`는 세션의 입력을 영영 닫아요.** 소켓은 EOF를 한 번만 알릴 수 없어서예요. | 끝낼 생각이 아니면 `C-c`나 프로그램의 종료 명령을 보내세요. | | **PTY가 없어요.** 직접 실행한 프로그램은 터미널을 보지 못해요. | `zeropty-tty`로 시작하세요. | 세션의 환경에서 `TMUX`와 `TMUX_PANE`은 지워요. 그래서 세션 안의 프로그램은 자기가 tmux 안에서 돈다고 생각하지 않아요. 종료 코드는 tmux를 따라요. 성공은 0, 실패는 1이에요. 자세한 내용은 [종료 코드](/ko/reference/keys-and-formats#종료-코드)를 보세요. ## 확인하기 바꾼 스크립트를 실행하고 에이전트를 기다리거나, claude 대신 `sh`로 이 확인을 해 보세요. ```sh [터미널] zeropty new-session -d -s demo -x 80 -y 6 sh zeropty send-keys -t '=demo:' -l 'echo "exit: $?"' zeropty send-keys -t '=demo:' Enter zeropty wait-pane -t '=demo:' -m 'exit: 0' -T 10000 zeropty list-sessions ``` tmux의 목록 형식 그대로 세션이 보여야 해요. `...` 자리에는 만든 시각이 들어가요. ```text demo: 1 windows (created ...) ``` `zeropty capture-pane -p -t '=demo:'`는 친 줄과 `exit: 0`을 보여 줘요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | 짧은 이름에 `can't find session`이나 `can't find pane`이 나와요 | 타깃은 정확히 일치해야 해요. 전체 이름을 쓰세요. | | tmux가 받던 옵션에 `unknown flag`가 나와요 | zeropty에는 없는 옵션이에요. [명령 레퍼런스](/ko/reference/commands#명령-레퍼런스)를 확인하세요. | | `split-window`나 `new-window`가 실패해요 | 창이 없어요. 프로그램마다 세션을 하나씩 시작하세요. | | 프로그램이 "터미널이 아니다"라며 다르게 동작해요 | `zeropty-tty`로 시작하세요. | | 내 셸의 변수가 새 세션에 없어요 | 서버는 시작할 때의 환경을 가져요. `-e`나 `set-environment -g`를 쓰세요. | | 포맷이 빈 문자열을 출력해요 | 모르는 변수이거나 조건식·수식어를 썼어요. [포맷 변수](/ko/reference/keys-and-formats#포맷-변수)를 보세요. | ## 다음 단계 - 기능별 비교 표는 [tmux 호환](/ko/reference/tmux-compatibility)를 보세요. - `wait-pane`, `grep-panes`, 빠른 경로는 [셸에서 에이전트 다루기](/ko/guide/shell)를 보세요. - [FAQ: tmux 스크립트를 그대로 쓸 수 있나요?](/ko/guide/faq#tmux-스크립트를-그대로-쓸-수-있나요) --- <!-- https://zeropty.pubdoc.dev/ko/guide/moving --> # 세션 옮기기와 서버 업그레이드 이 가이드를 마치면 프로그램이 계속 실행되는 상태로 zeropty 서버를 업그레이드하고, 실행 중인 세션을 두 서버 사이에서 옮길 수 있어요. 세션과 함께 가는 것과 남는 것도 알게 돼요. ## 시작하기 전에 - zeropty가 설치돼 있고 세션이 하나 이상 있는 서버가 필요해요. 아래 명령은 `work`라는 세션을 써요. `work`는 내 세션 이름으로 바꾸세요. - 업그레이드하려면 새 `zeropty` 바이너리가 필요해요. 더 새로운 릴리스 폴더나 새로 만든 `./build.sh` 결과예요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - 세션을 옮겨도 프로그램은 계속 살아 있지만, 백업은 아니에요. 세션은 어디에 있든 프로그램이 끝나면 끝나요. ## 1. 확인할 값 기록하기 바꾸기 전에 프로그램의 pid와 서버의 버전을 기록해 두세요. ```sh [터미널] zeropty display -p -t '=work:' '#{pane_pid}' zeropty display -p '#{version}' ``` `#{pane_pid}`는 프로그램의 pid이고, `#{version}`은 답하는 서버의 버전이에요. `zeropty -V`는 내가 실행한 CLI의 버전을 출력하므로 다를 수 있어요. ## 2. restart-server로 업그레이드하기 **새** 바이너리로 `restart-server`를 실행하세요. ```sh [터미널] zeropty restart-server ``` 방금 실행한 `zeropty` 바이너리로 새 서버가 시작되어 세션을 모두 넘겨받고, 그다음 소켓을 넘겨받아요. 프로그램은 멈추지 않고 pid도 그대로예요. 전환하는 몇 ms 사이에 들어온 명령은 옛 서버로 가요. 새 바이너리가 `PATH`에서 첫 번째가 아니라면 경로로 실행하세요. 예: `./zeropty-0.1.6-darwin-universal/zeropty restart-server` ::: warning 새 서버는 내 셸의 환경을 가져요 `restart-server`는 그 명령을 실행한 셸에서 새 서버를 띄워요. 이후에 만드는 세션은 그 셸의 환경을 받고, `set-environment -g`로 정한 값은 사라지니 다시 정하세요. 이미 실행 중인 세션은 자기 환경을 그대로 가져요. `ZEROPTY_PLUGIN_DIR`와 `ZEROPTY_SOCK`도 이 셸에서 와요. ::: ## 3. 서버 사이에서 세션 옮기기 `pull-sessions`는 다른 서버의 세션을 내 `zeropty`가 말하는 서버로 가져와요. `push-sessions`는 반대 방향으로 보내요. 다른 서버는 `-s`에 소켓으로 지정해요. ```sh [터미널] zeropty pull-sessions -s ~/.zeropty/other.sock # all of the other server's sessions zeropty pull-sessions -s ~/.zeropty/other.sock work build # only these zeropty push-sessions -s ~/.zeropty/other.sock work # this server's work to the other server ``` `~/.zeropty/other.sock`은 다른 서버의 소켓으로 바꾸세요. 내 쪽에 실행 중인 서버가 없으면 `pull-sessions`가 서버를 띄워요. 보내는 쪽은 받는 쪽이 받았다고 확인한 세션만 놓아요. 중간에 실패하면 세션은 원래 쪽에 그대로 있어요. ## 4. 임베디드 앱을 재시작해도 세션 유지하기 임베디드 엔진의 세션은 앱과 함께 끝나요. 같은 명령이 거기서도 되므로, 끝나기 전에 서버에 넘기고 시작한 뒤 다시 가져오세요. ```js [app.mjs] import { zeropty } from './client/embedded.mjs' const server = `${process.env.HOME}/.zeropty/mux.sock` await zeropty(['pull-sessions', '-s', server]) // on start: take the sessions back // ... run your app ... await zeropty(['push-sessions', '-s', server]) // before exit: hand them to the server ``` 임베딩 쪽은 [Node.js에서 zeropty 쓰기](/ko/guide/node)와 [C와 Rust에 zeropty 내장하기](/ko/guide/embed)를 보세요. ## 5. 무엇이 같이 가는지 알기 | 세션과 함께 가는 것 | 가지 않는 것 | |---|---| | 실행 중인 프로그램과 그 pid | `pipe-pane` 파이프: 세션이 옮겨질 때 닫혀요 | | 화면과 스크롤백(바이트로) | `restart-server` 때 `set-environment -g`로 정한 값 | | 세션의 fd(fd 전달, SCM_RIGHTS) | 상대편에 이미 같은 이름이 있는 세션 | | | 옮기는 순간 반쯤 받은 이스케이프 시퀀스의 나머지: 글자로 찍혀요 | 플러그인은 넘어가지 않아요. 플러그인 디렉터리가 있으면 새 서버가 소켓을 넘겨받은 뒤 자기 플러그인을 시작하고, 그동안 수십 ms는 플러그인 명령이 `unknown command`를 돌려줄 수 있어요. 플러그인은 옮김을 이벤트로 봐요. 보내는 쪽에서는 이유가 `handed-over`인 `session-exited`, 받는 쪽에서는 `session-created`예요. 내부적으로 `pull-sessions`, `push-sessions`, `restart-server`는 소켓의 내부 요청 두 개, `export-sessions`와 `import-sessions`를 써요. 직접 보낼 일은 거의 없고, 레코드 형식은 버전마다 바뀔 수 있어요. 자세한 내용은 [소켓 프로토콜: export-sessions, import-sessions](/ko/reference/socket-protocol#소켓-프로토콜-export-sessions-import-sessions)를 보세요. ## 확인하기 1단계의 명령을 다시 실행하세요. ```sh [터미널] zeropty display -p -t '=work:' '#{pane_pid}' zeropty display -p '#{version}' ``` 이전과 같은 pid와 새 버전이 보여야 해요. 예를 들어 0.1.5로 올린 뒤 pid가 48213인 프로그램은 이렇게 나와요. ```text 48213 0.1.5 ``` `zeropty capture-pane -p -t '=work:'`는 업그레이드 전과 같은 화면을 보여 줘요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | 옮기기가 `duplicate session: NAME`을 내고 1로 끝나요 | 상대편에 같은 이름의 세션이 있어요. `rename-session`으로 한쪽의 이름을 바꾸고 다시 옮기세요. | | `session record of another version` | 양쪽의 zeropty 버전이 달라서 세션 레코드가 달라요. 양쪽을 같은 버전으로 실행하세요. | | `restart-server` 뒤 새 세션에 변수가 없어요 | 새 서버는 그 명령을 실행한 셸의 환경을 가져요. 그 셸에서 변수를 정하거나 `set-environment -g`를 다시 쓰세요. | | 업그레이드 직후 플러그인 명령이 `unknown command`예요 | 새 서버가 아직 플러그인을 시작하는 중이에요. 잠시 뒤에 다시 해 보세요. | | 다운그레이드 후 `C-d`가 세션에 닿지 않아요 | 0.1.2 이상에서 시작한 세션은 0.1.1 이하로 돌려보내지 마세요. | ## 다음 단계 - 세션이 프로세스를 바꿀 수 있는 이유는 [세션과 서버](/ko/guide/concepts/sessions)를 보세요. - 모든 옵션은 [서버와 세션 옮기기](/ko/reference/commands#서버와-세션-옮기기)를 보세요. - 소켓에서 일어나는 일은 [세션 옮기기](/ko/reference/socket-protocol#세션-옮기기)를 보세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/node --> # Node.js에서 zeropty 쓰기 이 가이드를 마치면 [Node.js](https://nodejs.org)에서 에이전트 호스트를 실행할 수 있어요. 세션을 시작하고, 일을 주고, 기다리고, 화면을 읽어요. 먼저 서버의 소켓으로 하고, 그다음 import 하나만 바꿔서 Node 프로세스 안에 엔진을 넣어요. ## 시작하기 전에 - 소스에서 빌드한 zeropty가 있는 Mac이 필요해요(`git clone`과 `./build.sh`). Node 클라이언트는 `dist/zeropty.node`를 불러오고 `dist/zeropty`를 실행해요. 릴리스 아카이브에는 `zeropty.node`도 클라이언트도 들어 있지 않아요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - N-API 6(`napi6`) 이상을 지원하는 Node.js 가 필요해요. 저장소의 예제는 Node 18 이상으로 돌려요. - 명령은 zeropty 폴더에서 실행하세요. 아래 import는 `./client/...`를 써요. 내 프로젝트에서는 그 폴더 안의 파일 경로로 import하세요. ## 1. 클라이언트 고르기 두 모듈은 같은 호출을 받고 같은 결과 `{ ok, code, out, err }`를 돌려줘요. | 모듈 | 세션이 사는 곳 | `send-keys` 한 번 | 이럴 때 써요 | |---|---|---|---| | `client/zeropty.mjs` | zeropty 서버 | 0.24~0.26ms | 세션이 앱보다 오래 살아야 하거나, 여러 프로그램이 세션을 함께 쓸 때 | | `client/embedded.mjs` | 내 Node 프로세스(**임베디드**) | 0.06~0.07ms | 앱이 세션을 직접 가질 때 | 네 가지 인터페이스를 비교한 내용은 [zeropty를 다루는 네 가지 방법](/ko/guide/concepts/interfaces)을 보세요. ## 2. 소켓으로 세션 다루기 `client/zeropty.mjs`는 서버 소켓(`~/.zeropty/mux.sock`, 또는 `ZEROPTY_SOCK`)에 연결을 하나 열어 두고 명령마다 그 연결로 보내요. 서버가 없으면 CLI를 실행하므로 첫 `new-session`이 서버를 띄워요. 아래는 [`examples/node/quickstart.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/quickstart.mjs)예요. ```js [examples/node/quickstart.mjs] import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { zeropty } from '../../client/zeropty.mjs' const sock = path.join(os.tmpdir(), `zeropty-quickstart-${process.pid}.sock`) const zp = (args, opts) => zeropty(args, { sock, ...opts }) // Start a session; with no server on the socket, new-session starts one (as tmux does). // A real host runs an agent here: dist/claude-zeropty, or any program. let r = await zp(['new-session', '-d', '-s', 'job', '-x', '80', '-y', '20', 'sh']) if (!r.ok) throw new Error(r.err) // Give it work. await zp(['send-keys', '-t', '=job:', '-l', 'for i in 1 2 3; do echo step $i; sleep 0.2; done; echo "finished: $?"']) await zp(['send-keys', '-t', '=job:', 'Enter']) // Wait until it is done: the text shows up (the typed line holds `$?`, not `0`) and the output // stays quiet for 300 ms. No polling: the server checks as output comes in. const t0 = performance.now() r = await zp(['wait-pane', '-t', '=job', '-m', 'finished: 0', '-q', '300', '-T', '10000']) console.log(`wait-pane: ${r.ok ? 'done' : r.err.trim()} after ${Math.round(performance.now() - t0)} ms`) // Read the result. const { out } = await zp(['capture-pane', '-p', '-t', '=job:']) console.log(out.trimEnd()) // Which sessions show a given text, across all of them at once. console.log((await zp(['grep-panes', 'finished: 0'])).out.trimEnd()) await zp(['kill-server']) fs.rmSync(sock, { force: true }) ``` 이 예제는 자기 소켓을 쓰므로 내 세션은 건드리지 않아요. 내 앱에서는 `sock`을 빼서 기본 서버를 쓰세요. - `zeropty(argv, { cwd, sock, timeout })`의 기본 타임아웃은 15초예요. - `wait-pane`은 연결을 따로 열어서, 기다리는 동안에도 다른 명령이 계속 흘러요. 기본 타임아웃이 없으니 `-T`나 `{ timeout }`으로 정하세요. - 실패한 명령은 예외를 던지지 않아요. `r.ok`, 또는 `r.code`와 `r.err`를 확인하세요. ## 3. 필요하면 동기로 부르기 `zeroptySync(argv, { cwd, sock })`는 같은 명령을 보내고 답이 올 때까지 스레드를 막아요. 명령당 0.13~0.26ms예요. `dist/zeropty.node`가 필요해요. ```js [app.mjs] import { zeroptySync } from './client/zeropty.mjs' const { out } = zeroptySync(['capture-pane', '-p', '-t', '=job:']) ``` `sockPath()`와 `cliPath()`는 클라이언트가 쓰는 소켓(`ZEROPTY_SOCK`)과 CLI(`ZEROPTY_BIN`)를 돌려줘요. ## 4. 대신 엔진을 내장하기 엔진을 내 프로세스 안에 넣으려면 import만 바꾸세요. 아래는 줄여 쓴 [`examples/node/embedded.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/embedded.mjs)예요. ```js [examples/node/embedded.mjs] import { zeropty } from '../../client/embedded.mjs' const r = await zeropty(['new-session', '-d', '-s', 'w', '-x', '60', '-y', '8', 'exec cat']) if (!r.ok) throw new Error(r.err) await zeropty(['send-keys', '-t', '=w:', '-l', 'hello from an embedded engine']) await zeropty(['send-keys', '-t', '=w:', 'Enter']) // cat prints the line back once it reads it. for (let i = 0; i < 100; i++) { const { out } = await zeropty(['capture-pane', '-p', '-t', '=w:']) if (out.split('hello').length === 3) { console.log(out.trimEnd()); break } await new Promise((done) => setTimeout(done, 10)) } // The sessions end with this process (their programs get EOF); kill-server ends them now. await zeropty(['kill-server']) ``` 엔진을 내장하면 세 가지가 달라져요. - **`wait-pane`이 없어요.** 함수 호출에는 답을 미룰 루프가 없어요. 위처럼 루프로 화면을 읽거나, 5단계처럼 소켓을 열고 거기서 `wait-pane`을 보내세요. - **세션이 내 프로세스와 함께 끝나요.** 남기려면 끝나기 전에 서버로 `push-sessions` 하세요. 자세한 내용은 [세션 옮기기와 서버 업그레이드](/ko/guide/moving)를 보세요. - **옵션은 `open`에 줘요.** 첫 명령 전에 `open({ history, sizeDir, polled })`를 부르세요. 엔진은 기본적으로 **polled**예요. Node의 이벤트 루프가 엔진을 돌리고 엔진은 자기 스레드가 없어요. worker 스레드에서 부르려면 `polled: false`를 줘서 엔진에 스레드를 주세요. ## 5. CLI용 소켓 열기 `listen(path)`는 `zeropty serve`와 같은 서버를 내 앱 안에서 돌려요. 그러면 CLI, `attach`, 다른 스크립트가 내 앱의 세션에 닿아요. ```js [app.mjs] import { zeropty, listen } from './client/embedded.mjs' listen(`${process.env.HOME}/.zeropty/myapp.sock`) // throws if another server already answers on this socket ``` ```sh [터미널] ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w ``` 경로는 103바이트 이하여야 해요. ## 확인하기 소켓 예제를 실행하세요. ```sh [터미널] node examples/node/quickstart.mjs ``` 대기 시간, 화면, `grep-panes` 일치 결과가 보여야 해요. `NNN`은 밀리초이고 실행할 때마다 달라요. ```text wait-pane: done after NNN ms for i in 1 2 3; do echo step $i; sleep 0.2; done; echo "finished: $?" step 1 step 2 step 3 finished: 0 job: finished: 0 ``` 그다음 `node examples/node/embedded.mjs`를 실행하세요. `capture-pane: … µs each` 시간이 출력되고, 이어서 그 줄이 두 번 나와요. 한 번은 친 대로 에코된 줄이고, 한 번은 `cat`이 되돌려 출력한 줄이에요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | `Cannot find module …/dist/zeropty.node` | `./build.sh`를 실행하거나, `ZEROPTY_NODE`를 모듈 경로로 지정하세요. | | `wait-pane` 호출이 끝나지 않아요 | 기본 타임아웃이 없어요. `-T`나 `{ timeout }`을 주세요. | | 임베디드 엔진에서 `wait-pane`이 실패해요 | 함수 호출로는 쓸 수 없어요. `capture-pane`을 루프로 돌리거나, `listen`을 열고 소켓 클라이언트를 쓰세요. | | `listen`이 예외를 던져요 | 그 경로에서 다른 서버가 답하고 있어요. 다른 경로를 고르거나 그 서버를 멈추세요. | | 내 `listen` 소켓에 붙은 플러그인 명령이 타임아웃돼요 | `await zeropty(...)`로 부르세요. `zeroptySync`는 플러그인이 도는 JS 스레드를 잡고 있어요. | ## 다음 단계 - 어떤 언어에서든 서버와 말하려면 [소켓 프로토콜](/ko/reference/socket-protocol#다른-프로그램에서-소켓)을 보세요. - Node의 모든 export와 `open` 옵션은 [Node.js API](/ko/reference/node-api)를 보세요. - Node 밖에서 같은 엔진을 쓰려면 [C와 Rust에 zeropty 내장하기](/ko/guide/embed)를 보세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/plugins-internals --> # 플러그인 내부 zeropty를 고치거나 호스트 루프를 직접 짜는 사람을 위한 내용이에요. 플러그인을 쓰는 방법과 규칙은 [플러그인 API](/ko/reference/plugin-api)에 있어요. ## 구조 플러그인 계층은 엔진 위, 소켓 서버 옆에 있어요. 의존은 아래로만 가요(`src/lib.rs`의 계층 표). ``` embed (Embedded, Polled) ─┐ zeropty serve (src/cli/serve.rs) ─┴→ host::HostLoop ──┬→ Service: server::Server (소켓, plugin-attach, 플러그인 명령 라우팅) └→ Service: plugin::Plugins (등록부: 이벤트 배달, 플러그인이 시킨 명령, 감시) ↓ engine (engine::tap: 원하는 종류의 세션 이벤트만 큐에 넣어요) ``` - **엔진** (`src/engine/tap.rs`): 세션 생성·종료·이름 변경·출력을 이벤트로 큐에 넣어요. 어떤 플러그인도 원하지 않는 종류는 마스크 분기 하나로 건너뛰고 아무것도 만들지 않아요. `Event`, `ExitReason`은 여기 정의된 순수 데이터라 엔진은 위 계층을 몰라요. `zeropty::plugin`이 같은 타입을 다시 내보내요. - **등록부** (`Plugins`): 호스트 루프의 `Service`예요. 서버와 똑같은 자리(`host::Service`)에 붙어서 `HostLoop`이 매 바퀴 서버 다음에 돌려요(`after_tick` = 이벤트 배달·플러그인이 시킨 명령 실행·감시). 플러그인 스레드가 루프를 깨우는 이벤트(`Plugins::attach`로 엔진 큐에 단 사용자 이벤트)는 서비스의 `owns`로 걸러져서 호스트 몫으로 새지 않아요. 잠들 시간은 서버의 `wait_timeout`과 함께 `Plugins::watchdog_ms`로 묶여요. - **서버** (`server::Server::set_plugins`): `list-plugins`, 플러그인 명령 라우팅(답을 기다리는 건 그 연결뿐), `plugin-attach`(연결을 플러그인으로 넘김)를 맡아요. - 그래서 세 호스트(`zeropty serve`, `Embedded`, `Polled`)가 같은 순서로 플러그인을 돌리고, 순서는 `host.rs`의 단위 테스트(`plugins_are_pumped_after_the_service_and_cap_the_wait`)가 가짜 엔진으로 확인해요. ## 플러그인이 없을 때의 비용 무엇을 더 하는지는 [PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#플러그인이-없을-때의-비용)에 있어요. 플러그인이 없을 때 실제로 재 본 값이에요. 2026-10-20, 같은 Mac mini(M1)에서 플러그인이 없는 `main` 015f4da와 이 브랜치 c7ef81f(`src/embed.rs`의 `Polled::timeout_ms` next_due, `command_start`, `process_within`이 지금 모양인 커밋)를 번갈아 3번씩 돌렸어요(부하 평균 2.5\~5.0이라 절대값보다 두 열을 비교해요). 범위는 3번 실행의 최소\~최대예요(hostloop_bench는 실행마다 마지막 세 라운드라 9개 값). | 측정 | `main` 015f4da | c7ef81f | |---|---|---| | polled 한 바퀴, 조용한 세션(`process()` + `timeout_ms()`, 중앙값) | 12.58\~12.63 us | 12.58\~12.61 us | | polled 한 바퀴, claude 출력을 재생하는 세션 | 12.54\~13.26 us | 12.60\~12.94 us | | `Polled::command` display-message | 446\~481 ns | 446\~470 ns | | `Embedded::command` display-message | 455\~492 ns | 457\~485 ns | | 임베디드 capture-pane, 조용한 세션, p50 | 4.3\~5.0 us | 3.7\~4.4 us | | 임베디드 capture-pane -e -S -200, 조용한 세션, p50 | 4.7\~5.5 us | 4.7\~5.4 us | | 임베디드 capture-pane, 출력 중인 세션(`BUSY=1`), p50 | 5.7\~11.7 us | 6.0\~13.5 us | | 임베디드 send-keys cooked, p50 | 40.0\~42.5 us | 41.0\~45.0 us | | 임베디드 send-keys raw, p50 | 30.8\~37.9 us | 32.9\~35.5 us | 모든 차이가 같은 빌드를 다시 돌린 흔들림 안에 있어요(범위가 서로 겹치고, 출력 중인 세션의 capture-pane처럼 흔들림이 큰 줄은 실행마다 순서가 바뀌어요). 다시 재려면 두 빌드에서 번갈아 돌려요. 이 표는 c7ef81f에서 쟀어요. 그 뒤 커밋들이 엔진에서 바꾼 건 `src/engine/session.rs`의 `if stats.tap` 안쪽과, 그 안에서만 불리는 `src/engine/tap.rs`의 `#[cold]` 함수(`copy`, `read_now`: 출력을 원하는 플러그인이 있을 때만 도는 64KB 조각 나누기와 바이트 예산)뿐이라, 플러그인이 없을 때 도는 코드는 c7ef81f와 같아요(`git diff c7ef81f..HEAD -- src/engine`). ```sh cargo run --release --example hostloop_bench # polled 한 바퀴와 명령 경로(N, ROUNDS로 조절, 라운드마다 중앙값) cargo run --release --example capture_bench # BUSY=1이면 출력 중인 세션 cargo run --release --example sendkeys_bench ``` `main`에서 재려면 `git archive main | tar -x -C <dir>`로 푼 뒤 `examples/hostloop_bench.rs`를 복사해 넣어요(`main`에는 없어요). 위 표는 각 실행의 마지막 세 라운드(앞 두 라운드는 데우기)예요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/plugins --> # 플러그인 작성하기 이 가이드를 마치면 C 라이브러리로 작은 **플러그인**을 만들어 zeropty 서버에 붙이고, 플러그인이 더한 명령을 CLI에서 부르고, 서버를 띄울 때마다 같이 실행되게 할 수 있어요. 플러그인은 `tally`예요. 만들어진 세션과 끝난 세션을 세고, 지금 살아 있는 세션이 몇 개인지 알려 줘요. ## 시작하기 전에 - `PATH`에 zeropty가 있는 Mac이 필요해요. - `bindings/c/include/zeropty.h`와 `examples/plugins/tally.c`가 있는 zeropty 저장소가 필요해요. 명령은 그 폴더에서 실행하세요. 플러그인 라이브러리에는 헤더만 있으면 돼요. `libzeropty_c`에 링크하지 않아요. - Xcode Command Line Tools의 `cc`가 필요해요. ## 1. 붙이는 방법 고르기 플러그인은 세션 이벤트를 듣고 명령을 더해요. 더한 명령은 내장 명령처럼 CLI, 소켓, 임베디드 호출에서 불러요. 붙이는 방법은 네 가지이고, 모두 같은 등록부, 이벤트, 권한 수준, 격리 규칙을 써요. | 방법 | 언어 | 어디서 돌아요 | 예제 | |---|---|---|---| | 소켓 (프로세스 밖) | 아무 언어 (소켓과 JSON) | 별도 프로세스 | `examples/plugins/hello.py`, `examples/plugins/notify.mjs` | | 플러그인 라이브러리 (`.dylib`) | C, 또는 C 함수를 내보낼 수 있는 언어 | `zeropty plugin-run`으로 별도 프로세스, 또는 호스트 안 | `examples/plugins/tally.c` | | C ABI(application binary interface) 콜백 | C, 또는 C 함수를 넘길 수 있는 언어 | 호스트 프로세스, 플러그인 스레드 | `examples/plugins/count.c` | | Rust 트레이트 | Rust | 호스트 프로세스, 플러그인 스레드 | `examples/plugin_activity.rs` | 이 가이드는 라이브러리를 만들어요. 같은 `.dylib`이 서버 옆에서도, 임베딩 호스트 안에서도 실행되기 때문이에요. 플러그인 코드는 엔진 스레드에서 돌지 않으므로, 느리거나 크래시하는 플러그인이 내 세션을 멈추지 않아요. ## 2. 플러그인 쓰기 라이브러리의 진입점은 `zeropty_plugin_init` 하나예요. 매니페스트(이름, 이벤트, 명령)와 콜백을 채워요. 콜백은 JSON 메시지를 한 번에 하나씩, 플러그인 자신의 스레드에서 받아요. ```c [examples/plugins/tally.c] #include <stdatomic.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include "zeropty.h" static const zeropty_plugin_api *zp; /* from the host: host_command and free */ static atomic_int created, ended; /* One JSON message at a time, on the plugin's own thread (a real plugin would use a JSON parser). */ static char *on_message(void *user, const char *msg, zeropty_host *host) { (void)user; if (strstr(msg, "{\"event\":\"session-created\"") == msg) created++; if (strstr(msg, "{\"event\":\"session-exited\"") == msg) ended++; if (strncmp(msg, "{\"call\"", 7) != 0) return NULL; /* start, events, stop: nothing to answer */ /* Asks zeropty, from inside the plugin, for the sessions running now: one "x" per session. */ char *r = zp->host_command(host, "{\"argv\":[\"list-sessions\",\"-F\",\"x\"],\"cwd\":\"/\"}"); int live = 0; const char *out = r ? strstr(r, "\"out\":\"") : NULL; for (const char *p = out ? out + 7 : ""; *p && *p != '"'; p++) live += *p == 'x'; zp->free(r); char *reply = malloc(128); /* zeropty frees it */ snprintf(reply, 128, "{\"code\":0,\"out\":\"created %d, ended %d, live %d\\n\",\"err\":\"\"}", created, ended, live); return reply; } int zeropty_plugin_init(const zeropty_plugin_api *api, zeropty_plugin_def *def) { if (api->api < 1) return 1; zp = api; def->manifest_json = "{\"name\":\"tally\",\"events\":[\"session-created\",\"session-exited\"],\"commands\":[\"tally\"]}"; def->callback = on_message; def->user = NULL; return 0; } ``` 콜백은 `{"start":{"api":1}}`을 가장 먼저 받고, 그다음 이벤트와 호출을 받고, `{"stop":true}`를 마지막으로 받아요. `call`에는 `code`, `out`, `err`가 든 답을 `malloc`해서 돌려주세요. zeropty가 해제해요. 그 밖의 메시지에는 `NULL`을 돌려주세요. `host_command`는 플러그인 안에서 zeropty 명령을 플러그인의 **권한(access)** 범위에서 실행해요. 위 매니페스트는 권한을 밝히지 않았으므로 기본값인 `read`를 받아요. `list-sessions`와 `capture-pane`은 되지만 `send-keys`는 안 돼요. 키를 보내려면 매니페스트에 `"access":["input"]`을 더하세요. ## 3. 빌드하기 헤더만으로 라이브러리를 빌드해요. ```sh [터미널] cc -dynamiclib -O2 -Ibindings/c/include -o /tmp/tally.dylib examples/plugins/tally.c ``` ## 4. 서버에 붙이기 서버가 없으면 하나 띄우고, `plugin-run`으로 라이브러리를 실행한 다음 그 명령을 불러요. ```sh [터미널] zeropty new-session -d -s a sh # starts the server if none is running zeropty plugin-run /tmp/tally.dylib & zeropty tally ``` `plugin-run`은 라이브러리를 자기 프로세스에 불러와서 소켓으로 서버에 붙여요. 라이브러리가 크래시하면 그 프로세스만 죽어요. 서버가 끝나면 `plugin-run`도 끝나요. ## 5. 서버를 띄울 때마다 같이 시작하기 라이브러리를 플러그인 디렉터리에 넣고 `ZEROPTY_PLUGIN_DIR`로 그 디렉터리를 가리키세요. 서버는 그 안의 플러그인을 시작하고, 끝나면 다시 시작해요. ```sh [터미널] export ZEROPTY_PLUGIN_DIR=~/.zeropty/plugins # put this in ~/.zshrc to keep it always on mkdir -p ~/.zeropty/plugins cp /tmp/tally.dylib ~/.zeropty/plugins/ # a .dylib is started with zeropty plugin-run zeropty restart-server # takes effect from the next server ``` ::: warning 기본은 꺼져 있어요 `ZEROPTY_PLUGIN_DIR`가 없으면 `~/.zeropty/plugins`가 있어도 서버는 아무것도 실행하지 않아요. 이 값을 지정하는 건 그 디렉터리의 코드를 내 사용자 권한으로 실행해도 된다는 동의예요. 다른 사용자가 쓸 수 있는 디렉터리와 파일은 건너뛰어요. ::: - 서버는 자기를 띄운 셸에서 `ZEROPTY_PLUGIN_DIR`를 가져가요. 서버를 띄우는 `new-session`이나 `restart-server`를 실행한 셸이에요. - 실행 파일과 `.dylib` 파일은 이름순으로 시작해요. 각 플러그인의 출력은 디렉터리의 `NAME.log`로 가요. - 금방 끝나는 플러그인은 0.5초 뒤에 다시 시작하고, 이후 두 배씩 늘려 최대 30초까지 기다려요. - 플러그인은 `restart-server`를 지나도 남아요. 옛 서버의 `plugin-run`은 옛 서버와 함께 끝나고, 새 서버가 소켓을 넘겨받은 뒤 자기 플러그인을 시작해요. ## 6. 프로그램에서 부르기 플러그인 명령은 다른 명령과 똑같이 불러요. 엔진을 내장한 호스트에서는 부른 스레드가 기다릴지 말지 고를 수 있어요. | 호스트 | 답을 기다려요 | 바로 돌아와요 | |---|---|---| | C | `zeropty_command` | `zeropty_command_start` → `zeropty_call_poll` → `zeropty_call_free` | | Rust `Embedded`, `Polled` | `command(argv, cwd)` | `command_start(argv, cwd) -> Call` | | Node (`client/embedded.mjs`) | `zeroptySync(argv)` | `await zeropty(argv)` | 엔진을 내장한 호스트는 `zeropty_load_plugin(a, "/tmp/tally.dylib")`로 같은 라이브러리를 자기 프로세스에 불러올 수도 있어요. 그러면 라이브러리의 크래시는 그 프로세스의 크래시가 돼요. ## 확인하기 세션 두 개를 시작하고 하나를 끝낸 다음, 명령을 부르고 플러그인 목록을 보세요. ```sh [터미널] zeropty new-session -d -s b sh zeropty new-session -d -s c sh zeropty kill-session -t '=c:' zeropty tally zeropty list-plugins ``` 플러그인이 시작된 뒤의 개수가 보여야 해요. `live`는 실행 중인 모든 세션을 세므로, `a`와 `b`만 남았을 때는 이렇게 나와요. ```text created 2, ended 1, live 2 ``` `list-plugins`는 플러그인마다 `name: state (api N) events=... commands=... access=... dropped=N` 형태로 한 줄씩 출력해요. `tally: running`을 찾아보세요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | `tally`가 `unknown command`예요 | 플러그인이 붙어 있지 않아요. `zeropty list-plugins`를 확인하세요. `restart-server` 직후에는 수십 ms 안에 플러그인이 다시 붙어요. | | `failed (stuck in a hook ...)` | 콜백이 마감 시간(10초, `ZEROPTY_PLUGIN_TIMEOUT_MS`)보다 오래 실행됐어요. 먼저 "started"로 답하고 결과는 다른 명령으로 가져가게 하세요. | | `plugin tally lacks access input` | 매니페스트에 권한을 더하세요. 예: `"access":["input"]` | | `bad manifest: ...` | JSON 매니페스트에는 `name`, `events`, `commands`, `access`, `api`만 쓸 수 있어요. `"event"` 같은 오타가 없는지 확인하세요. | | `dropped=`가 계속 늘어요 | 플러그인이 이벤트를 너무 느리게 읽어요. 큐에는 이벤트 1024개나 출력 8MB까지 들어가요. `output`보다 `activity`를 쓰세요. | | 플러그인 디렉터리가 아무 일도 안 해요 | 서버를 띄운 셸에 `ZEROPTY_PLUGIN_DIR`가 없었어요. 지정한 뒤 `zeropty restart-server`를 실행하세요. | ## 다음 단계 - 모든 [이벤트](/ko/reference/plugin-api#이벤트)와 [권한 수준](/ko/reference/plugin-api#권한-access)은 플러그인 API를 보세요. - Python이나 Node.js로 플러그인을 쓰려면 [플러그인용 소켓 프로토콜](/ko/reference/plugin-api#1-다른-프로세스에서-소켓-프로토콜-언어-무관)을 보세요. - 내 프로세스에서 플러그인을 호스팅하려면 [C와 Rust에 zeropty 내장하기](/ko/guide/embed)를 보세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/releasing --> # 릴리스 변경이 커밋에서 CHANGELOG를 거쳐 릴리스가 되기까지의 과정이에요. 무엇이 깨는 변경인지는 [버전 정책](/ko/guide/versioning), 커밋 형식은 [기여 안내](/ko/guide/contributing)에 있어요. ## 한눈에 보기 ``` 커밋 (Conventional Commits) ──▶ PR: scripts/check.sh가 커밋 형식과 CHANGELOG [Unreleased]를 확인 │ ▼ main에 병합 릴리스 PR: node scripts/release.mjs bump <단계> ──▶ 버전을 모든 곳에서 한 번에 바꾸고 [Unreleased]를 ## [X.Y.Z] - 날짜로 옮김 │ ▼ 병합 후, 관리자가 태그 vX.Y.Z (release.mjs check --tag가 태그와 버전을 대조) 태그 push: .github/workflows/release.yml ──▶ macOS·Linux 묶음을 빌드·스모크 테스트하고, 그것들과 SHA256SUMS를 GitHub Release에 붙임 (릴리스는 CHANGELOG로 만듦) ``` - **변경 기록의 원천은 커밋이에요.** 모든 커밋은 [Conventional Commits](https://www.conventionalcommits.org/ko/v1.0.0/) 형식이고(`scripts/check.sh`와 `.githooks/commit-msg`가 확인), 그 타입으로 릴리스 단계가 정해져요. - **CHANGELOG.md는 사람이 읽는 글이에요.** [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) 형식으로, 사용자에게 보이는 변경(`feat`, `fix`, `perf`, `revert`, 깨는 변경)은 같은 PR에서 `## [Unreleased]` 아래에 한 줄씩 적어요. `node scripts/release.mjs notes`가 커밋에서 초안을 만들어 줘요([git-cliff](https://git-cliff.org), 설정은 `cliff.toml`). - CHANGELOG 항목은 문서처럼 영어로 쓰고, 같은 항목을 한국어 사본 [Changelog](/ko/guide/changelog)에도 적어요. git-cliff 초안은 커밋 메시지에서 나오니, 그걸 읽고 사용자에게 맞는 문장으로 다시 써요. 섹션 이름(`Added`, `Changed` ...)과 코드·식별자는 두 파일 모두 그대로예요. - **문장은 사람이, 구조는 기계가 맞춰요.** PR마다 `scripts/check.sh`(`check-commits.mjs --changelog`)가 `[Unreleased]`에 새로 생긴 항목의 섹션을 git-cliff 초안의 섹션과 대조해요(아래 [반자동인 이유](/ko/guide/releasing#반자동인-이유)). - **공개 인터페이스는 기계로도 확인해요.** `api/c-abi.txt`(`libzeropty_c.dylib`의 `zeropty_*` 심볼과 `zeropty.h` 선언)와 `api/node.txt`(Node 모듈 내보내기)가 기준선이에요. `scripts/check.sh`가 빌드 결과와 비교하고(`node scripts/check-api.mjs`), PR에서 기준선의 줄이 사라지면 깨는 변경 표시(`!`·`BREAKING CHANGE:`)와 `**BREAKING**` 항목을 요구해요([버전 정책](/ko/guide/versioning#기계-검사)). - 이 저장소의 도구는 **커밋, 태그, push, 배포를 하지 않아요.** 파일만 바꾸고, 나머지는 사람이 확인하고 해요. ## 반자동인 이유 CHANGELOG를 git-cliff 출력 그대로 쓰지 않고, 초안을 사람이 사용자에게 맞게 다시 써요. 일부러 고른 방식이에요. - **독자가 달라요.** 커밋 메시지는 코드를 고치는 사람에게 쓰는 한 줄이고, CHANGELOG는 zeropty를 쓰는 사람에게 쓰는 문서예요. 커밋 제목 나열로는 "무엇을 바꿔야 하는지"가 빠져요. - **여러 커밋이 한 항목이 돼요.** 기능 하나가 `feat` 커밋 여러 개로 나뉘거나, 아직 릴리스하지 않은 기능을 같은 주기에 `fix`로 고치면, 초안에는 여러 줄이지만 사용자에게는 한 줄이에요. - **대신 빠뜨리거나 엉뚱한 곳에 적는 실수는 `scripts/check.sh`가 막아요.** `check-commits.mjs --changelog`는 PR의 커밋을 `cliff.toml`과 같은 규칙(`scripts/lib/conventional.mjs`의 `sectionsFor`)으로 분류하고, `[Unreleased]`에서 **새로 생기거나 바뀐 항목**이 그 섹션에 있는지 봐요. | 커밋 | git-cliff 초안의 섹션 | 검사가 요구하는 새 항목 | |---|---|---| | `feat` | Added | `### Added` 아래 | | `fix` | Fixed | `### Fixed` 아래 | | `fix(security)` | Security | `### Security` 아래 | | `perf`, `revert` | Changed | `### Changed` 아래 | | 깨는 변경(`!`, `BREAKING CHANGE:`) | Changed | `### Changed`나 `### Removed` 아래 `- **BREAKING** ...` | | `refactor`, `docs`, `test`, `build`, `ci`, `chore`, `style` | (빠짐) | 없음 | | 아무 type + `Changelog: skip` 꼬리말 (깨는 변경 제외) | (빠짐) | 없음 | - `[Unreleased]`의 제목은 `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`만, 이 순서로, 한 번씩 쓸 수 있어요. 항목은 모두 `###` 제목 아래에 있어야 해요. `ZEROPTY_SKIP_CHANGELOG=1`이어도 항상 확인해요. - 문장은 비교하지 않아요. 항목은 초안을 옮기지 않고 다시 쓰니, 섹션만 맞으면 통과예요. - 두 분류 규칙(`cliff.toml`과 `sectionsFor`)이 어긋나면 `scripts/test/cliff.test.mjs`가 실패해요. 실제 git-cliff로 커밋을 렌더링해서 각 줄의 섹션을 `sectionsFor`와 비교해요. - 아직 릴리스하지 않은 기능(`[Unreleased]`에 있는 기능)을 고친 `fix`처럼 사용자에게 새 소식이 아닌 커밋은 커밋 메시지 끝에 `Changelog: skip` 꼬리말을 달아요. 그 커밋만 검사와 git-cliff 초안에서 빠지고, 같은 PR의 다른 커밋은 그대로 검사돼요. 필요하면 기존 항목의 문장을 고쳐요. 깨는 변경에는 쓸 수 없어요(커밋 검사가 거부해요). - PR 전체가 사용자에게 보이지 않는데 type이 `feat`/`fix`라면 PR 설명에 그렇게 적고, 관리자가 `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh`로 항목 검사를 건너뛰어요. 이때도 커밋 형식 검사는 해요. ## 버전이 적힌 곳 `release.mjs bump`가 아래를 한 번에 바꾸고, `tests/version.rs`·`bindings/c` 단위 테스트·`release.mjs check`가 서로 같은지 확인해요. | 파일 | 내용 | |---|---| | `Cargo.toml` | `[workspace.package] version`. 세 크레이트가 `version.workspace = true`로 물려받아요 | | `Cargo.lock` | `zeropty`, `zeropty-c`, `zeropty-node` 항목 | | `bindings/c/include/zeropty.h` | `ZEROPTY_VERSION_MAJOR`·`MINOR`·`PATCH`·`PRERELEASE`·`IS_PRERELEASE`·`STRING` (`NUMBER`는 계산돼요) | | `client/zeropty.mjs`, `client/embedded.mjs` | `export const VERSION` | | `CHANGELOG.md` | `## [X.Y.Z] - YYYY-MM-DD`와 맨 아래 비교 링크 | `zeropty --version`, 서버의 `#{version}`, `zeropty_version()`, Node의 `version()`은 빌드할 때 Cargo 버전에서 나와서 따로 고칠 곳이 없어요. `bump`는 한국어 사본 [Changelog](/ko/guide/changelog)를 건드리지 않아요. 릴리스 섹션을 다듬을 때(아래 4단계) 같은 제목과 항목을 그곳에도 손으로 옮겨요. CHANGELOG 맨 아래 링크는 **있는 태그만** 가리켜요. 이전 버전의 태그가 있으면 `compare/vA...vB`, 이전 버전에 태그가 없으면 `[Unreleased]` 링크가 시작하는 커밋에서 시작해요. 없는 태그를 가리키는 링크가 생기면 `node --test scripts/test`가 실패해요. 단, 지금 릴리스하는 버전의 태그는 예외예요(릴리스 PR이 그 링크를 넣고, 태그는 머지 뒤에 달아요). ## 명령 ```sh node scripts/release.mjs check # 모든 곳의 버전이 같은지 (scripts/check.sh도 실행) node scripts/release.mjs check --tag v0.2.0 # 그리고 태그가 그 버전인지 (태그가 달린 HEAD에서 scripts/check.sh도 실행) node scripts/release.mjs next # 마지막 태그 이후 커밋으로 계산한 다음 버전 node scripts/release.mjs notes # [Unreleased] 초안 (git-cliff, 없으면 npx로 받아요) node scripts/release.mjs release-notes 0.2.0 # GitHub Release 본문 (CHANGELOG.md의 ## [0.2.0]으로 만들어요) node scripts/release.mjs bump minor --dry-run node scripts/release.mjs bump <major|minor|patch|auto|X.Y.Z[-pre]> [--date YYYY-MM-DD] [--generate] node scripts/check-commits.mjs --range origin/main..HEAD --changelog # scripts/check.sh의 커밋·변경 기록 검사만 (섹션 대조 포함) scripts/smoke-version.sh # dist/의 CLI·서버·C·Node 버전이 같은지 (scripts/check.sh의 version smoke tests 단계) scripts/package.sh # dist/release/에 릴리스 묶음과 SHA256SUMS를 만들고 푼 상태로 스모크 테스트 (rustup target add aarch64-apple-darwin x86_64-apple-darwin 필요) rustup run 1.88.0 cargo check --workspace --all-targets --locked # MSRV (scripts/check.sh의 MSRV 단계) node scripts/check-api.mjs # dist/(./build.sh)의 C ABI·Node 내보내기가 api/*.txt와 같은지 node scripts/check-api.mjs --base origin/main # 기준선에서 사라진 줄이 깨는 변경으로 표시됐는지 (PR 검사) node scripts/check-api.mjs --update # 인터페이스를 일부러 바꿨을 때 기준선 갱신 (같은 커밋에) node --test scripts/test/*.test.mjs # 도구 자체의 테스트 (git-cliff 렌더링 포함) ``` - `bump auto`는 `next`가 계산한 단계를 써요. 1.0 이전에는 깨는 변경이 있으면 MINOR, 아니면 PATCH예요. - `bump`는 먼저 `check`가 통과하는지, 작업 트리가 깨끗한지 보고, 하나라도 실패하면 아무 파일도 바꾸지 않아요. - `[Unreleased]`가 비어 있으면 멈춰요. `--generate`를 주면 git-cliff 초안으로 채워요(그래도 읽고 고쳐 써요). - 버전을 바꾼 뒤 `cargo metadata --locked`로 `Cargo.lock`이 맞는지 확인해요. ## 릴리스 절차 1. **main에서 `scripts/check.sh`가 통과하는지 확인해요.** `--quick` 없이 모든 단계(MSRV, 예제, rustdoc 포함)가 통과해야 해요. 2. **단계를 정해요.** ```sh git switch main && git pull node scripts/release.mjs next ``` 계산 결과를 [버전 정책](/ko/guide/versioning)의 규칙과 맞춰 봐요. MSRV를 올렸거나 `build`·`refactor` 커밋이 사용자에게 보이는 변화를 냈다면 계산에 안 잡히니 직접 단계를 골라요. 3. **릴리스 브랜치에서 버전을 올려요.** ```sh git switch -c release/0.2.0 node scripts/release.mjs bump minor # 또는 auto, 0.2.0, 0.2.0-rc.1 ``` 4. **CHANGELOG를 다듬어요.** `## [0.2.0]` 섹션을 사용자 입장에서 읽어요. - 같은 일을 한 줄로 묶고, 내부 사정은 빼요. - 깨는 변경은 `**BREAKING**`과 함께 무엇을 바꿔야 하는지 적어요. - 성능 수치를 적는다면 [벤치마크](/ko/guide/benchmarks)에 측정된 값만 써요. - 같은 변경을 한국어 사본 [Changelog](/ko/guide/changelog)에도 해요. 5. **확인해요.** ```sh cargo build --release --workspace && cargo test --release --workspace && ./build.sh dist/zeropty --version # zeropty 0.2.0 node scripts/release.mjs check ``` 6. **커밋하고 PR을 올려요.** 커밋 메시지는 `chore(release): 0.2.0`이에요(git-cliff가 이 커밋은 CHANGELOG에서 빼요). PR 제목도 같게 해요. 7. **병합한 뒤 관리자가 태그를 만들어요.** ```sh git switch main && git pull git tag -a v0.2.0 -m "zeropty 0.2.0" git push origin v0.2.0 ``` push하기 전에 `node scripts/release.mjs check --tag v0.2.0`으로 태그와 버전이 같은지 확인해요(태그가 달린 HEAD에서는 `scripts/check.sh`도 이 검사를 해요). 8. **태그가 릴리스를 올려요.** `.github/workflows/release.yml`(macOS runner, Linux 묶음은 Linux runner)이 태그와 버전을 대조하고 `scripts/package.sh`와 `scripts/package-linux.sh`를 돌려, 그 파일들을 릴리스에 붙여요. - `zeropty-0.2.0-darwin-universal.tar.gz`: 폴더 `zeropty-0.2.0-darwin-universal/` 하나에 `zeropty`(arm64 + x86_64, `lipo`), `shim.dylib`(arm64, arm64e, x86_64), `zeropty-tty`, `claude-zeropty`, `LICENSE-APACHE`, `LICENSE-MIT`가 실행 권한 그대로 들어 있어요. `zeropty`와 `shim.dylib`는 ad-hoc 서명(`codesign -s -`)이에요. Developer ID 서명과 공증은 나중에 더할 수 있어요. - `zeropty-0.2.0-linux-x86_64.tar.gz`: 같은 이름의 폴더 하나에 `zeropty`, `shim.so`(`LD_PRELOAD`용), `zeropty-tty`, `claude-zeropty`, 라이선스가 있어요. `package-linux` job이 Debian 11(`rust:1.88-bullseye`)에서 `scripts/package-linux.sh`로 만들어서 glibc 2.29 이상이 필요해요. 스크립트가 파일마다 가장 새 glibc 심볼을 출력하고, 푼 묶음을 직접 돌려 확인해요. - `SHA256SUMS`: 모든 묶음의 SHA-256. 두 묶음이 다 만들어지면 publish job이 만들어요. 버전을 고정하는 쪽은 이 해시를 고정해요. 태그에 릴리스가 아직 없으면 워크플로가 `node scripts/release.mjs release-notes 0.2.0`을 본문으로 만들어요(CHANGELOG 섹션을 항목당 한 줄로, 상대 링크는 태그 기준 절대 링크로). 프리릴리스 부분이 있는 태그(`v0.2.0-rc.1`)는 프리릴리스가 되고 "Latest"가 되지 않아요. 워크플로가 닿기 전(push 후 1분쯤)에 손으로 만든 릴리스는 본문을 그대로 둬요. Actions에서 실행 결과를 보고, 파일들을 받아 `shasum -a 256 -c SHA256SUMS`를 돌려 확인해요. 두 바이너리는 macOS 11 이상에서 로드돼요(`package.sh`가 슬라이스마다 최소 버전을 확인해요). - 워크플로는 릴리스에 이미 있는 파일을 바꾸지 않아요. 다시 빌드하면 바이트가 같지 않아서, 해시가 바뀌면 이전 해시를 고정한 쪽이 깨지기 때문이에요. 일부러 바꾸려면 릴리스에서 파일을 지운 뒤 다시 돌려요. - 다시 돌리면 태그가 가리키는 커밋을 그대로 다시 빌드해요. 그래서 저장소 밖의 실패(runner, 네트워크)에만 도움이 되고, 묶음 쪽을 고친 내용은 새 커밋과 다음 PATCH 릴리스로 내보내야 해요. - 임베딩 라이브러리와 bash builtin은 묶음에 없고 `./build.sh`로 만들어요. > 0.1.0은 첫 공개 커밋(`c612089`)이고 `v0.1.0` 태그가 달려 있어요. 0.1.1이 이 도구로 만든 첫 릴리스예요. `next`·`notes`는 마지막 태그부터 계산해요. ## 포맷 워크스페이스 전체가 rustfmt 형식이에요(설정은 `rustfmt.toml`). 진행 중인 브랜치가 main에 다 들어간 뒤 `style: cargo fmt --all` 커밋(`e12f84f`) 하나로 한 번에 포맷했어요. - `scripts/check.sh`의 rustfmt 단계가 `cargo fmt --all --check`로 트리 전체를 확인해요. 커밋 전에 `cargo fmt --all`을 돌려요. - 포맷 커밋 앞에서 갈라진 오래된 브랜치는 충돌한 파일에서 자기 쪽을 고르고 `cargo fmt --all`을 다시 돌리면 돼요. 포맷은 기계적이라 결과가 같아요. - `git blame`에서 포맷 커밋을 건너뛰려면 `git blame --ignore-rev e12f84f <파일>`로 봐요. ## 프리릴리스와 패치 릴리스 - **프리릴리스:** `node scripts/release.mjs bump 0.2.0-rc.1`. 태그는 `v0.2.0-rc.1`. 정식 릴리스는 `bump patch`(`0.2.0-rc.1` → `0.2.0`)나 `bump 0.2.0`이에요. 헤더의 `ZEROPTY_VERSION_PATCH`와 `ZEROPTY_VERSION_NUMBER`는 숫자만 담아서 `0.2.0-rc.1`과 `0.2.0`이 같아요. 둘은 `ZEROPTY_VERSION_PRERELEASE`(`"rc.1"`, 정식은 `""`)와 `ZEROPTY_VERSION_IS_PRERELEASE`(`#if`로 쓸 수 있는 1/0), `ZEROPTY_VERSION_STRING`(`"0.2.0-rc.1"`)으로 구별해요. - **이전 버전 수정(backport):** 태그에서 브랜치를 만들어요(`git switch -c release/0.1.x v0.1.0`). 수정 커밋을 cherry-pick하고, `[Unreleased]`에 적고, `bump patch`로 같은 절차를 따라요. main의 CHANGELOG에도 그 버전 섹션을 옮겨 적어요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | `tests/version.rs` 실패 | 버전을 손으로 고쳤어요. `node scripts/release.mjs check`로 어긋난 곳을 보고, `bump`로 다시 맞춰요 | | `scripts/check.sh`의 commit messages 단계: 커밋 형식 | `git rebase -i`로 메시지를 고쳐요(`reword`). fixup 커밋은 `--autosquash`로 합쳐요 | | `scripts/check.sh`의 commit messages 단계: changelog | `## [Unreleased]`에 줄을 더해요. 사용자에게 안 보이는 변경이면 `ZEROPTY_SKIP_CHANGELOG=1 scripts/check.sh`로 항목 검사를 건너뛰어요 | | `needs an entry under ### Fixed for: ...` | 항목을 다른 섹션에 적었어요. 커밋 type에 맞는 섹션으로 옮겨요([반자동인 이유](/ko/guide/releasing#반자동인-이유)의 표). `node scripts/release.mjs notes`가 git-cliff의 분류를 보여줘요 | | `[Unreleased]: "### Fixes" is not one of ...` | 제목은 Keep a Changelog의 여섯 개만, `Added` → `Security` 순서로 써요 | | 깨는 변경인데 `**BREAKING**` 줄이 없다고 실패 | `[Unreleased]`에 `- **BREAKING** ...` 줄을 더해요 | | `check-api.mjs`: `api/*.txt does not match the build` | 인터페이스를 일부러 바꿨다면 `node scripts/check-api.mjs --update` 후 `api/`를 같이 커밋해요. 아니라면 바뀐 선언·내보내기를 되돌려요 | | `check-api.mjs --base`: `public API removed or changed` | 기준선에서 줄이 사라졌어요(함수 제거, 시그니처 변경). 의도했다면 커밋에 `!`나 `BREAKING CHANGE:`, `[Unreleased]`에 `- **BREAKING** ...`을 더하고, 아니라면 새 이름으로 더하는 방식으로 바꿔요 | | `scripts/check.sh`의 rustfmt 단계 실패 | `cargo fmt --all`로 포맷하고 커밋해요([포맷](/ko/guide/releasing#포맷)) | | `scripts/check.sh`의 MSRV 단계: `Rust 1.88.0 is not installed` | `rustup toolchain install 1.88.0` | | `bump`가 `nothing under ## [Unreleased]`로 멈춤 | 항목을 쓰거나 `--generate`를 줘요 | | 태그를 잘못 만들었어요 | push 전이면 `git tag -d vX.Y.Z`. push한 뒤라면 지우지 말고 다음 PATCH를 내요 | --- <!-- https://zeropty.pubdoc.dev/ko/guide/shell --> # 셸에서 에이전트 다루기 이 가이드를 마치면 셸 스크립트로 여러 세션을 다룰 수 있어요. 세션을 시작하고, 입력하고, 각각이 끝날 때까지 기다리고, 조건에 맞는 세션을 찾아요. 명령마다 프로세스를 띄우지 않고 소켓에 바로 말하는 빠른 경로로 스크립트를 바꾸는 방법도 다뤄요. ## 시작하기 전에 - `PATH`에 zeropty가 있는 Mac이 필요해요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - bash builtin을 쓰려면 소스에서 빌드해야 해요(`./build.sh`가 `dist/zeropty_bash.dylib`를 만들어요). 릴리스 아카이브에는 들어 있지 않아요. - 아래 단계는 각 세션에서 `sh`를 실행하므로 claude가 없어도 돼요. 실제 스크립트에서는 대신 에이전트를 실행해요. 예를 들어 `dist/claude-zeropty`예요([claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs) 참고). ## 1. 세션 시작하기 **세션**은 **서버**가 계속 실행해 주는 프로그램 하나예요. 서버가 없으면 첫 `new-session`이 서버를 띄워요. ```sh [터미널] zeropty new-session -d -s build -x 80 -y 6 sh ``` `-d`는 내 터미널을 그대로 두고, `-s build`는 세션 이름을 정하고, `-x`/`-y`는 화면 크기를 정해요. 자세한 내용은 [세션과 서버](/ko/guide/concepts/sessions)를 보세요. ## 2. 키 보내기 모든 명령은 **타깃**(`-t`)으로 세션을 가리켜요. 스크립트에서는 `'=build:'`처럼 따옴표로 감싸서 써요. ```sh [터미널] zeropty send-keys -t '=build:' -l 'ls / | head -3; echo "exit: $?"' zeropty send-keys -t '=build:' Enter ``` `send-keys`의 인자는 하나하나가 키 이름이거나 글자 그대로의 텍스트예요. | 이렇게 쓰면 | 이렇게 보내요 | |---|---| | `Enter`, `C-c`, `Up`, `F5` | 그 키의 바이트([키 이름](/ko/reference/keys-and-formats#키-이름)) | | `-l 'Enter'` | `Enter`라는 다섯 글자. 키가 아니에요 | | `-H 41 42 0a` | 16진수 바이트 그대로(`AB`와 줄바꿈) | | `-N 3 Down` | 인자 전체를 세 번 | 프롬프트나 변수에서 온 텍스트에는 항상 `-l`을 쓰세요. 그러면 그 안의 `Enter`나 `Space` 같은 단어가 키로 해석되지 않아요. `Enter`는 `-l` 없이 별도 명령으로 보내요. ::: warning zsh에서는 타깃에 따옴표를 쓰세요 zsh는 `=word`를 명령의 경로로 바꿔요(`=` 확장). `=build:`가 아니라 `'=build:'`로 쓰세요. tmux 타깃도 같은 문제가 있어요. ::: ## 3. 결과 기다리기 `wait-pane`은 글자가 나타나거나(`-m`), 출력이 멈추거나(`-q`), 둘 다 만족할 때 답해요. `-T`는 타임아웃이에요. ```sh [터미널] zeropty wait-pane -t '=build:' -m 'exit: 0' -T 10000 ``` 서버는 출력이 올 때만 확인하므로 폴링이 없어요. 친 키는 화면에 에코되므로, 결과에서만 출력되는 글자를 기다리세요. 위에서 친 줄에는 `0`이 아니라 `$?`가 들어 있어서, `exit: 0`은 출력에만 일치해요. 마지막에 어떤 글자가 나올지 알 수 없는 에이전트라면 대신 조용해질 때까지 기다려요. ```sh [터미널] zeropty wait-pane -t '=fix-1:' -q 5000 -T 600000 # until output pauses for 5 s, at most 10 minutes ``` `wait-pane`은 시간이 초과되거나(`wait-pane: timed out`) 세션이 끝나면 종료 코드 1로 끝나요. 자세한 내용은 [화면, 키, 기다리기](/ko/guide/concepts/screens)를 보세요. ## 4. 화면 읽기 `capture-pane -p`는 화면을 출력해요. 밀린 출력을 먼저 처리하므로 화면은 늘 최신이에요. ```sh [터미널] zeropty capture-pane -p -t '=build:' # the visible screen zeropty capture-pane -p -e -S -200 -t '=build:' # plus 200 lines of scrollback, with colors zeropty capture-pane -p -J -S - -E - -t '=build:' # everything, wrapped lines joined ``` | 옵션 | 효과 | |---|---| | `-p` | 표준 출력으로 내보내요(없으면 화면이 버퍼에 들어가요) | | `-e` | 색·글꼴 이스케이프를 남겨요 | | `-J` | 줄바꿈으로 나뉜 줄을 이어 붙여요 | | `-N` | 줄 끝 공백을 남겨요 | | `-S`, `-E` | 첫 줄과 마지막 줄. 0이 화면 첫 줄, 음수가 스크롤백, `-`는 끝까지예요 | 모든 옵션은 [화면 읽기](/ko/reference/commands#화면-읽기)를 보세요. ## 5. 여러 세션 다루기 작업마다 세션을 하나씩 시작한 다음, 각각을 기다리고 `grep-panes`로 한꺼번에 찾아요. 아래 내용을 `jobs.sh`로 저장하세요. ```sh [jobs.sh] #!/bin/sh set -e for n in 1 2 3; do zeropty new-session -d -s "job-$n" -x 80 -y 10 sh zeropty send-keys -t "=job-$n:" -l "sleep $n; echo \"finished: \$?\"" zeropty send-keys -t "=job-$n:" Enter done for n in 1 2 3; do zeropty wait-pane -t "=job-$n:" -m 'finished: 0' -T 30000 done zeropty grep-panes 'finished: 0' ``` `grep-panes`는 일치한 줄을 `세션: 줄` 형태로 세션 이름순으로 출력하고, 일치하는 게 없으면 종료 코드 1로 끝나요. `-S -100`을 더하면 스크롤백까지 찾아요. 세션 1000개에서 16~32ms 걸렸어요. 화면을 하나씩 읽지 않고도 입력을 기다리는 에이전트를 찾을 때 써요. ## 6. 빠른 경로 쓰기 `zeropty` 프로그램을 실행하면 명령마다 프로세스가 떠요(3.59~4.23ms). zsh 함수와 bash builtin은 대신 셸 안에서 소켓으로 명령을 바로 보내요. ::: code-group ```sh [zsh (~/.zshrc)] source /path/to/zeropty/shell/zeropty.zsh ``` ```sh [bash] enable -f /path/to/zeropty/dist/zeropty_bash.dylib zeropty ``` ::: `/path/to/zeropty`는 clone한 폴더로 바꾸세요. 스크립트는 그대로 두면 돼요. 이제 `zeropty`가 함수 또는 builtin이 될 뿐이에요. | 경로 | 측정값 | |---|---| | zsh 함수 | `send-keys -l` p50 0.12ms(tmux CLI보다 50.9~52.7배 빨라요), `has-session` 0.11~0.17ms, 2000줄 `capture-pane` 0.50~0.55ms | | bash builtin | 명령당 0.041~0.138ms(tmux CLI보다 48.7~157배 빨라요), macOS의 `/bin/bash` 3.2에서 확인했어요 | 둘 다 `attach`, `serve`, `restart-server`, `-V`, `--help`, 인자 없는 실행, 서버가 없을 때의 모든 명령은 여전히 프로그램을 실행해요. `ZEROPTY_BIN`을 지정하면 다른 프로그램을 가리킬 수 있어요. ## 확인하기 스크립트를 실행한 뒤 화면 하나를 읽어 보세요. ```sh [터미널] sh jobs.sh zeropty capture-pane -p -t '=job-2:' ``` `grep-panes`가 세션마다 한 줄씩 보여 줘야 해요. ```text job-1: finished: 0 job-2: finished: 0 job-3: finished: 0 ``` `job-2`의 화면에는 친 줄이 먼저 나오고, 그다음에 `finished: 0`이 나와요. ```text sleep 2; echo "finished: $?" finished: 0 ``` `zeropty kill-server`로 정리하세요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | `wait-pane -m`이 바로 끝나요 | 글자가 내가 친 줄에 들어 있어요. 결과에서만 출력되는 글자를 기다리세요. | | 글자가 나왔는데도 `wait-pane: session ended`가 나와요 | 프로그램이 출력 직후에 끝났어요. 살아 있게 하세요. 예: `sh -c 'make; echo build done; exec cat'` | | 보이는 세션인데 `can't find session: NAME` 또는 `can't find pane: NAME`이 나와요 | 타깃은 정확히 일치해야 해요. 앞부분 일치는 없어요. `zeropty ls`로 확인하세요. | | `no server running on ...` | 그 소켓에서 듣는 서버가 없어요. `new-session`으로 서버를 띄우거나 `ZEROPTY_SOCK`을 지정하세요. | | `send-keys: unknown flag -X` | 모르는 옵션은 오류예요. [키 보내기](/ko/reference/commands#키-보내기)를 보세요. | | `C-d` 뒤로 입력이 안 들어가요 | 빈 줄의 `C-d`는 세션의 입력을 영영 닫아요. 대신 `C-c`나 프로그램의 종료 명령을 보내세요. | ## 다음 단계 - [claude와 다른 터미널 프로그램 실행하기](/ko/guide/terminal-programs)에서 이 세션에 에이전트를 넣어 보세요. - [Node.js에서 zeropty 쓰기](/ko/guide/node)에서 명령마다 프로세스를 띄우지 않고 같은 흐름을 만들어 보세요. - [기다리기와 찾기](/ko/reference/commands#기다리기와-찾기)에서 `wait-pane`과 `grep-panes`의 모든 옵션을, [종료 코드](/ko/reference/keys-and-formats#종료-코드)에서 종료 코드를 확인하세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/terminal-programs --> # claude와 다른 터미널 프로그램 실행하기 이 가이드를 마치면 터미널이 필요한 프로그램을 zeropty 세션에서 실행할 수 있어요. python REPL, [Claude Code](https://docs.anthropic.com/en/docs/claude-code)(`claude`), 그리고 `C-c`로 작업을 멈출 수 있는 대화형 셸이에요. ## 시작하기 전에 - zeropty가 설치된 Mac이나 Linux가 필요해요. `zeropty-tty`, `claude-zeropty`, shim(`shim.dylib`, Linux는 `shim.so`)이 한 폴더에 있어야 해요. 릴리스 아카이브와 `./build.sh`(`dist/`)는 모두 그 폴더에 넣어 줘요. `cargo install`은 그렇지 않아요. 자세한 내용은 [설치와 지원 플랫폼](/ko/guide/install)을 보세요. - 셸에서 `C-c`가 작업에 닿으려면(4단계) zeropty 0.1.5 이상이 필요해요. `zeropty -V`로 확인하세요. - 3단계에는 `PATH`에 `claude`가 있어야 해요. 아래 명령은 `dist/zeropty-tty`를 써요. 릴리스 폴더를 쓴다면 그 폴더의 경로로 바꾸세요. ## 1. 프로그램에 터미널이 필요한 이유 보기 세션의 터미널은 PTY(pseudo-terminal)가 아니라 소켓이에요. `isatty()`로 터미널인지 확인하는 프로그램은 터미널이 아니라고 판단해요. vim, less, python REPL, claude 모두 다르게 동작해요. 자세한 내용은 [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal)을 보세요. **`zeropty-tty`**가 이 문제를 풀어요. 프로그램을 **shim**(macOS는 `DYLD_INSERT_LIBRARIES`로 넣는 `shim.dylib`, Linux는 `LD_PRELOAD`로 넣는 `shim.so`)과 함께 시작하고, shim이 터미널처럼 다음 호출에 답해요. | 호출 | 프로그램이 받는 것 | |---|---| | `isatty`, `tcgetattr` | 세션 자신의 소켓 위의 터미널 | | `TIOCGWINSZ` | 세션의 크기 | | `tcgetpgrp`, `tcsetpgrp` | 포그라운드 프로세스 그룹 | | `/dev/tty` | 읽고 쓸 수 있는 세션의 소켓 | | `readpassphrase`, `getpass` | 에코하지 않는 프롬프트(0.1.4부터) | 세션의 소켓만 터미널로 쳐요. stdout을 파이프로 넘기거나(`zeropty-tty prog | cat`) stdin이 `/dev/null`인 프로그램은 거기서 터미널을 보지 못해요. tmux에서도 마찬가지예요. ## 2. zeropty-tty로 프로그램 시작하기 명령 앞에 `zeropty-tty`를 붙여요. ```sh [터미널] zeropty new-session -d -s py -x 80 -y 10 "dist/zeropty-tty python3" zeropty send-keys -t '=py:' -l 'print(6 * 7)' zeropty send-keys -t '=py:' Enter zeropty wait-pane -t '=py:' -m '42' -T 10000 ``` vim도 같은 방식이에요. `zeropty new-session -d -s v "dist/zeropty-tty vim notes.txt"`로 시작해요. 프로그램이 raw 모드로 바꾸면 shim이 알려 주고, 키는 그대로 전달돼요. macOS는 SIP로 보호되는 바이너리(`/usr/bin`, `/bin`, `/sbin`, `/System`)와 hardened runtime으로 서명된 바이너리에는 shim을 넣지 못하게 해요. `zeropty-tty`는 이런 프로그램을 서명만 바꾼 사본으로 실행하고, 사본은 `~/.zeropty/tty/`(`ZEROPTY_CACHE`)에 둬요. 원본이 바뀌면 사본을 다시 만들고, 내가 준 이름으로 실행하므로 `ps`와 `pgrep`에서 찾을 수 있어요. ## 3. claude-zeropty로 claude 시작하기 **`claude-zeropty`**는 `zeropty-tty claude`를 실행하는 작은 래퍼예요. ```sh [터미널] zeropty new-session -d -s fix-1 -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" ``` `~/proj`는 에이전트가 일할 저장소로 바꾸세요. 다른 claude를 쓰려면 `ZEROPTY_CLAUDE_REAL`에 경로를 주세요. 세션은 서버의 환경을 받으므로 서버가 시작되기 전에 설정하거나, `-e`로 넘겨요. ```sh [터미널] zeropty new-session -d -s fix-2 -e ZEROPTY_CLAUDE_REAL=/opt/claude/bin/claude "$PWD/dist/claude-zeropty" ``` `/opt/claude/bin/claude`는 내 claude의 경로로 바꾸세요. [셸에서 에이전트 다루기](/ko/guide/shell)처럼 에이전트에 일을 주고 기다리세요. ## 4. 대화형 셸 실행하고 작업 멈추기 세션의 셸에 명령을 직접 치려면 `zeropty-tty`로 셸을 시작하세요. ```sh [터미널] zeropty new-session -d -s sh1 "dist/zeropty-tty zsh" zeropty send-keys -t '=sh1:' -l 'sleep 30' zeropty send-keys -t '=sh1:' Enter zeropty send-keys -t '=sh1:' C-c ``` `C-c`, `C-\`, `C-z`는 터미널이 보내는 것처럼 시그널로 포그라운드 작업에 전달돼요. 셸의 `tcsetpgrp`가 shim을 거쳐 zeropty에 전달되므로 셸 자체가 shim과 함께 실행돼야 해요. 작업은 `/usr/bin`을 포함해 어떤 프로그램이든 돼요. `C-z`와 `fg`도 파이프라인을 포함해 동작해요. 세션 프로그램 아래에 멤버가 있는 프로세스 그룹만 받아들여요. ::: warning 셸에서 직접 친 프로그램 세션의 셸에서 직접 친 `/usr/bin` 프로그램에는 shim이 들어가지 않아요. 그 프로그램들은 shim 없이 실행돼요. 시그널은 여전히 닿지만, 거기서 친 `ssh`, `ssh-keygen`, `ssh-add`는 암호를 화면과 스크롤백에 그대로 에코해요. 대신 `zeropty-tty ssh-keygen`처럼 별도의 세션으로 시작하세요. ::: ## 확인하기 python 세션을 읽어 보세요. ```sh [터미널] zeropty capture-pane -p -t '=py:' ``` REPL의 배너가 나오고, 그다음에 프롬프트, 내가 친 줄, 답이 보여야 해요. ```text >>> print(6 * 7) 42 >>> ``` `>>>` 프롬프트와 줄 편집은 REPL의 터미널 모드예요. REPL은 터미널을 볼 때만 이 모드를 켜요. 4단계의 셸에서는 `zeropty capture-pane -p -t '=sh1:'`가 `sleep 30`이 계속 자고 있는 세션이 아니라, 그 뒤에 새 프롬프트가 나온 화면을 보여 줘요. ## 문제가 생기면 | 증상 | 해결 | |---|---| | 프로그램이 "터미널이 아니다"라며 다르게 동작해요 | `zeropty-tty`로 시작하세요. claude는 `claude-zeropty`를 쓰세요. | | `/usr/bin/env`를 거쳐 시작한 스크립트에서 shim이 사라져요 | SIP가 거기서 shim을 지워요. 인터프리터를 직접 시작하세요: `zeropty-tty python3 script.py`. | | `top`이나 `ps`가 실행되지 않아요 | 사본에는 원본의 권한(setuid, entitlement)이 없어요. 그 권한이 필요한 프로그램은 동작하지 않아요. | | `C-c`가 셸의 프롬프트는 끝내지만 `sleep 30`은 못 끝내요 | 0.1.5로 올리기 전에 시작한 셸이라 옛 shim을 그대로 갖고 있어요. 셸을 다시 시작하세요. | | 암호가 화면에 보여요 | 프로그램을 셸에서 직접 쳐서 shim 없이 실행됐어요. `zeropty-tty`로 시작하세요. | | `zeropty-tty`가 shim을 찾지 못해요 | 폴더를 한데 두거나, `ZEROPTY_SHIM`이 shim(`shim.dylib`나 `shim.so`)을 가리키게 하세요. | ## 다음 단계 - shim과 라인 디시플린은 [세션이 터미널을 얻는 방법](/ko/guide/concepts/terminal)을 보세요. - 에이전트에 일을 주고 기다리려면 [셸에서 에이전트 다루기](/ko/guide/shell)를 보세요. - `ZEROPTY_SHIM`, `ZEROPTY_CACHE`, `ZEROPTY_CLAUDE_REAL`은 [환경변수](/ko/reference/environment)를 보세요. --- <!-- https://zeropty.pubdoc.dev/ko/guide/versioning --> # 버전 정책 zeropty는 [Semantic Versioning 2.0.0](https://semver.org/lang/ko/)을 따라요. 이 문서는 **무엇이 공개 인터페이스이고, 어떤 변경이 호환을 깨는지**를 정해요. 변경 내역은 [Changelog](/ko/guide/changelog), 릴리스 절차는 [릴리스](/ko/guide/releasing)에 있어요. ## 버전 하나 - 저장소 전체가 **버전 하나**를 써요. `zeropty`, `zeropty-c`, `zeropty-node` 크레이트는 `Cargo.toml`의 `[workspace.package] version`을 물려받고, C 헤더의 `ZEROPTY_VERSION_*`, Node 클라이언트의 `VERSION`, `CHANGELOG.md`도 같은 값이에요. - 버전은 손으로 고치지 않고 `node scripts/release.mjs bump ...`로만 바꿔요. 어긋나면 `cargo test`(`tests/version.rs`)와 `scripts/check.sh`가 실패해요. - 태그는 `v` + 버전이에요(`v0.1.0`). 프리릴리스는 `0.2.0-rc.1`처럼 써요. 실행 중에 버전을 확인하는 방법: | 어디서 | 방법 | 결과 | |---|---|---| | CLI | `zeropty --version` 또는 `zeropty -V` | `zeropty 0.1.0` (CLI 프로그램 자신) | | 실행 중인 서버 | `zeropty display-message -p '#{version}'` (tmux와 같은 포맷 변수. `#{version}`이 있고 세션 변수가 없는 포맷만 세션 없이 답하고, `-t` 없이 세션도 없으면 다른 포맷은 전처럼 종료 코드 1이에요) | `0.1.0` | | C ABI | `zeropty_version()` (런타임), `ZEROPTY_VERSION_STRING`·`_MAJOR`·`_MINOR`·`_PATCH`·`_PRERELEASE`·`_IS_PRERELEASE`·`_NUMBER` (컴파일 타임) | `"0.1.0"`. `ZEROPTY_VERSION_NUMBER`는 `MAJOR*1000000 + MINOR*1000 + PATCH`라서 **프리릴리스를 담지 않아요**: `0.2.0-rc.1`과 `0.2.0`이 같은 값이에요. 구별은 `ZEROPTY_VERSION_PRERELEASE`(`"rc.1"`, 정식은 `""`)나 `#if ZEROPTY_VERSION_IS_PRERELEASE`로 해요 | | Node (임베디드) | `version()` (`zeropty.node`, `client/embedded.mjs`) | `'0.1.0'` | | Node (클라이언트 모듈) | `VERSION` (`client/zeropty.mjs`, `client/embedded.mjs`) | `'0.1.0'` | 헤더와 라이브러리는 같은 빌드에서 나와야 해요. 다른 MAJOR(1.0 이전에는 다른 MINOR)의 라이브러리는 헤더 선언과 맞지 않을 수 있으니, 임베드하는 쪽은 시작할 때 `zeropty_version()`을 `ZEROPTY_VERSION_STRING`과 비교하면 좋아요. ## 버전을 올리는 규칙 | 변경 | 1.0 이전 (지금, `0.MINOR.PATCH`) | 1.0 이후 | |---|---|---| | 아래 공개 인터페이스의 호환을 깨는 변경 | **MINOR** (`0.1.x` → `0.2.0`) | **MAJOR** | | 기능 추가 (새 명령·플래그·함수·환경변수), 하위 호환 | PATCH | MINOR | | 버그 수정, 성능 개선, 문서 | PATCH | PATCH | | MSRV 올림, 지원 macOS 버전 변경 | MINOR | MINOR | - 1.0 이전에는 Cargo와 같은 해석을 써요. `0.1.x`끼리는 서로 호환되고, `0.2.0`은 깨질 수 있어요. - 커밋 메시지로 올릴 단계를 계산해요(`node scripts/release.mjs next`). `!`나 `BREAKING CHANGE:` 꼬리말이 있으면 깨는 변경, `feat`는 기능 추가, `fix`·`perf`·`revert`는 수정이에요. 자세한 형식은 [기여 안내](/ko/guide/contributing)에 있어요. - 깨는 변경은 CHANGELOG에 `**BREAKING**`으로 시작하는 줄과, 사용자가 무엇을 바꿔야 하는지를 적어요. `scripts/check.sh`가 확인해요. ## 공개 인터페이스 (호환을 지키는 범위) 아래를 바꾸거나 없애면 **깨는 변경**이에요. 새로 더하는 건(새 명령, 새 플래그, 새 함수, 새 JSON 필드, 새 환경변수) 깨는 변경이 아니에요. ### 1. CLI - 명령 이름과 별칭(`new-session`/`new`, `attach-session`/`attach`/`a` ...), 플래그와 그 의미, tmux와 같은 인자 해석(`-t =이름:` 타깃 형식 포함). - 종료 코드: 성공 0, 실패 1(`has-session` 없음, `wait-pane` 시간 초과, `grep-panes` 못 찾음, 모르는 옵션 ...), 인자 없이 실행하면 2. - 프로그램이 읽는 출력 형식: `list-sessions` 기본 출력과 `-F`, `display-message -p` 포맷 변수, `new-session -P`, `grep-panes`의 `세션: 줄`, `zeropty --version`의 `zeropty X.Y.Z`. - `capture-pane` 출력 바이트. 같은 화면이면 같은 바이트여야 해요. tmux와 다르게 나오던 걸 맞추는 건 버그 수정(`fix`)이고, 의도해서 형식을 바꾸는 건 깨는 변경이에요. - 기본 소켓 경로(`~/.zeropty/mux.sock`)와 첫 `new-session`이 서버를 띄우는 동작. - 사람이 보는 오류 문구(`err`)는 공개 인터페이스가 아니에요. 종료 코드로 판단해요. ### 2. 소켓 프로토콜 - 요청 한 줄: `{"argv": [...], "cwd": "...", "raw": bool}`. 응답 한 줄: `{"code", "out", "err"}`. 한 연결에 여러 요청, 답은 보낸 순서대로. - `"raw": true` 응답 형식(`<code> <out 바이트 수> <err 바이트 수>\n` + 바이트). - `attach`의 프레임 형식과 종류 값(`FRAME_OUTPUT`, `FRAME_ENDED`, `FRAME_INPUT`, `FRAME_RESIZE`, `FRAME_DETACH`). - 새 요청 필드는 더할 수 있어요. 옛 클라이언트가 보내지 않아도 지금과 똑같이 동작해야 해요. ### 3. 세션 옮기기 레코드 - `pull-sessions`·`push-sessions`·`restart-server`가 주고받는 세션 레코드(`RECORD_VERSION`, 지금 1). **`restart-server`가 업그레이드 경로**라서, 새 버전 서버가 이전 버전 레코드를 못 읽으면 실행 중인 세션을 잃지 않고는 업그레이드할 수 없어요. - 그래서 `RECORD_VERSION`을 올리면 깨는 변경이에요. 올려야 한다면 이전 레코드도 읽도록 하고, CHANGELOG에 어느 버전부터 어느 버전으로 `restart-server`가 되는지 적어요. ### 4. C ABI (`bindings/c/include/zeropty.h`, `libzeropty_c.dylib`) - 함수 이름, 인자와 반환 타입, 의미, 스레드 규칙(`zeropty_open`은 아무 스레드, polled는 루프 스레드 하나). - 메모리 소유권: `zeropty_command`의 결과는 `zeropty_free`로, `zeropty_version()`의 문자열은 해제하지 않음. - 옵션 JSON의 키(`history`, `size_dir`)와 요청·응답 JSON(소켓 프로토콜과 같음). - `zeropty`는 불투명 타입이에요. 크기와 내부 배치는 공개 인터페이스가 아니에요. - 헤더의 `ZEROPTY_VERSION_*` 매크로 이름과 의미. - 함수를 더하는 건 깨는 변경이 아니에요. 있는 함수의 시그니처를 바꾸는 대신 새 이름으로 더해요. ### 5. Node API - `zeropty.node`의 내보내기: `Engine`(생성자 옵션 `history`, `sizeDir`, `polled`, `command`, `listen`), `Client`(`command`, `close`), `version()`. - `client/zeropty.mjs`: `zeropty`, `zeroptySync`, `sockPath`, `cliPath`, `VERSION`와 결과 형태 `{ ok, code, out, err }`, 옵션(`cwd`, `sock`, `timeout`). - `client/embedded.mjs`: `zeropty`, `zeroptySync`, `open`, `listen`, `version`, `VERSION`. 두 모듈은 import만 바꿔서 서로 바꿔 쓸 수 있어야 해요. - 지원하는 Node: N-API 6 이상(`napi6`). ### 6. 환경변수 | 공개 (이름·의미·기본값을 지켜요) | 내부 (예고 없이 바뀔 수 있어요) | |---|---| | `ZEROPTY_SOCK`, `ZEROPTY_HISTORY`, `ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`, `ZEROPTY_COLD`, `ZEROPTY_COLD_DIR`, `ZEROPTY_RECORD` ([환경변수](/ko/reference/environment#환경변수)) | `ZEROPTY_COLS`, `ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE`, `ZEROPTY_TTY_ID` (엔진이 shim에 넘기는 값, 같은 빌드끼리만 써요) | | `ZEROPTY_BIN`, `ZEROPTY_NODE` (클라이언트가 쓸 바이너리·모듈), `ZEROPTY_SHIM`, `ZEROPTY_CACHE`, `ZEROPTY_CLAUDE_REAL` (`zeropty-tty`, `claude-zeropty`) | `ZEROPTY_MALLOC_ADDED` (서버 재실행용), `ZEROPTY_PURGE_MS` (측정용) | | `ZEROPTY_SESSION` (세션 안 프로그램이 읽는 세션 이름) | 테스트·벤치마크에서만 쓰는 나머지 `ZEROPTY_*` | 기본값을 바꾸는 것도 동작이 달라지면 깨는 변경이에요. 단, 성능 조정용 기본값(`ZEROPTY_COALESCE_MS`, `ZEROPTY_FREEZE_MS`)은 화면을 읽는 결과가 같다면 수정으로 봐요. 이때도 CHANGELOG의 Changed에 적어요. ### 7. 빌드 결과물과 셸 연동 - `./build.sh`가 `dist/`에 만드는 파일 이름: `zeropty`, `libzeropty_c.dylib`, `zeropty.h`, `zeropty.node`, `shim.dylib`, `zeropty_bash.dylib`, `claude-zeropty`, `zeropty-tty`. - zsh 함수(`shell/zeropty.zsh`)와 bash builtin(`enable -f ... zeropty`)의 이름과 동작(CLI와 같아야 해요). ## 기계 검사 규칙을 사람의 눈에만 맡기지 않아요. | 무엇을 | 어떻게 | 어디서 | |---|---|---| | C ABI (4절) | `nm -gU dist/libzeropty_c.dylib`의 `zeropty_*` 심볼과 `zeropty.h`의 선언(매개변수 이름·주석·줄바꿈은 빼고)·매크로 이름을 `api/c-abi.txt`와 비교 | `node scripts/check-api.mjs` (`scripts/check.sh`의 public API 단계) | | Node API (5절) | `zeropty.node`, `client/zeropty.mjs`, `client/embedded.mjs`의 내보내기 이름과 종류, 클래스의 메서드를 `api/node.txt`와 비교 | 같음 | | 깨는 변경 표시 | PR에서 `api/*.txt`의 줄이 사라지거나 바뀌면, 커밋(또는 PR 제목)에 `!`·`BREAKING CHANGE:`가 있고 `[Unreleased]`에 `- **BREAKING**` 줄이 새로 있어야 통과 | `node scripts/check-api.mjs --base <base>` (`scripts/check.sh`의 public API 단계) | | 버전 일치 | 크레이트, `Cargo.lock`, 헤더 매크로, Node `VERSION`, `--version`, `#{version}`, CHANGELOG | `tests/version.rs`, `node scripts/release.mjs check` | | MSRV | Rust 1.88.0으로 `cargo check --workspace --all-targets --locked` | `scripts/check.sh`의 MSRV 단계 | | CLI·소켓 프로토콜·레코드 | 동작을 `tests/server.rs`, `tests/handover.rs`, `tests/embed.rs`가 실제 서버와 클라이언트로 확인 | `cargo test` | 새 함수나 내보내기는 기준선에 줄이 생길 뿐이라 깨는 변경이 아니지만, 빌드와 기준선이 다르면 `scripts/check.sh`가 실패하니 `node scripts/check-api.mjs --update`로 갱신한 `api/`를 같은 커밋에 넣어요. Rust 라이브러리 API는 공개 인터페이스가 아니라서(아래) `cargo-semver-checks`는 쓰지 않아요. 공개하기로 하면 그때 `scripts/check.sh`에 더해요. ## 공개 인터페이스가 아닌 것 - **Rust 라이브러리 API**(`zeropty::engine`, `zeropty::term`, `zeropty::server` ...). crates.io에 올리지 않았고, 바인딩과 서버가 같은 저장소에서 함께 바뀌어요. 1.0 전에 공개 여부를 따로 정해요. - 성능 수치. 느려지는 건 호환 문제가 아니라 회귀로 다뤄요([AGENTS.md](https://github.com/CreatiCoding/zeropty/blob/main/AGENTS.md)의 10배 목표, [벤치마크](/ko/guide/benchmarks)). - `bench/`, `examples/`, `tests/`, `scripts/`의 내용과 진단 명령(`stats`, `mstats`, `mem`)의 출력 형식. - 오류 문구, 로그, 레코드 파일(`ZEROPTY_RECORD`)의 내부 형식. ## 폐기(deprecation) - 1.0 이후에는 없애기 전에 최소 한 번의 MINOR 릴리스 동안 폐기 예정으로 두어요. 그동안 계속 동작하고, CHANGELOG의 **Deprecated**에 대체 방법을 적어요. 다음 MAJOR에서 **Removed**로 없애요. - 1.0 이전에는 MINOR 릴리스에서 바로 없앨 수 있지만, 할 수 있으면 같은 절차를 따라요. ## MSRV (최소 지원 Rust 버전) - **Rust 1.88** (`Cargo.toml`의 `rust-version`). 의존성 `napi` 3이 1.88을 요구해요. edition은 2024예요. - `scripts/check.sh`의 MSRV 단계가 정확히 1.88.0으로(`rustup run 1.88.0`) 워크스페이스 전체를 `cargo check --locked` 해요. - MSRV는 MINOR 릴리스에서만 올리고, CHANGELOG의 Changed에 적어요. 올릴 때는 `Cargo.toml`의 `rust-version`, `scripts/check.sh`의 MSRV 단계, 이 문서를 같은 커밋에서 바꿔요. - 플랫폼: macOS, 그리고 Linux x86_64(glibc 2.29 이상). 검사(`scripts/check.sh`)는 관리자의 Apple Silicon Mac에서 돌고, Linux 테스트는 CI(`.github/workflows/release.yml`의 `test-linux`)에서 돌아요. ## 판단 예시 | 변경 | 분류 | 커밋 | |---|---|---| | `capture-pane`에 `-C` 플래그 추가 | 기능 추가 | `feat(cli): support capture-pane -C` | | `wait-pane` 시간 초과 종료 코드를 1에서 2로 | 깨는 변경 | `fix(cli)!: exit 2 when wait-pane times out` | | 세션 없을 때 실패(1)하던 `display -p '#{version}'`이 서버 버전을 출력(0). 다른 포맷은 여전히 1 | 기능 추가 (전에 오류였던 입력만 새로 동작해요. 성공하던 입력의 결과는 그대로예요) | `feat(cli): add the #{version} format variable` | | 실패(1)하던 `display -p -t =gone: '...'`이 tmux처럼 세션 변수를 비운 포맷을 출력(0) | 버그 수정 (오류였던 입력이 tmux처럼 동작해요. 성공하던 것은 그대로예요) | `fix(cli): answer display-message on a missing target as tmux does` | | 이미 성공하던 명령의 종료 코드나 출력 형식이 바뀜 | 깨는 변경 | `feat(cli)!: ...` | | C ABI에 `zeropty_command_len()` 추가 | 기능 추가 | `feat(c-abi): add zeropty_command_len` | | `zeropty_open` 인자 추가 | 깨는 변경 (대신 새 함수 추가를 권해요) | `feat(c-abi)!: ...` | | 응답 JSON에 `"pid"` 필드 추가 | 기능 추가 | `feat(protocol): ...` | | `RECORD_VERSION` 2로, 1도 계속 읽음 | 기능 추가 | `feat(handover): ...` | | `RECORD_VERSION` 2로, 1은 못 읽음 | 깨는 변경 | `feat(handover)!: ...` | | 같은 화면에서 `capture-pane -e` 바이트가 tmux와 다르던 것 맞춤 | 수정 | `fix(term): ...` | | 화면 해석 속도 2배 | 수정 (성능) | `perf(term): ...` | | `ZEROPTY_SOCK` 이름 변경 | 깨는 변경 | `refactor!: ...` 또는 `feat!: ...` | | MSRV 1.88 → 1.90 | MINOR | `build: raise MSRV to 1.90` | --- <!-- https://zeropty.pubdoc.dev/ko/reference/c-abi --> # C ABI `libzeropty_c.dylib`의 헤더 `zeropty.h`에 있는 모든 함수, 타입, 매크로예요. 빌드, 링크, 첫 프로그램은 [C와 Rust에 zeropty 내장하기](/ko/guide/embed)를 보세요. 빌드가 이 목록을 검사해요. `api/c-abi.txt`를 라이브러리, 헤더와 비교하고, 거기 있는 심볼이 이 페이지에 없으면 사이트 빌드가 실패해요. 원본은 [`bindings/c/include/zeropty.h`](https://github.com/CreatiCoding/zeropty/blob/main/bindings/c/include/zeropty.h)예요. ## 모든 호출의 규칙 | 규칙 | 내용 | |---|---| | 요청과 답은 JSON | 요청은 `{"argv": [...], "cwd": "/"}`이고 argv는 CLI와 같아요. 답은 `{"code": 0, "out": "...", "err": ""}`예요. | | 답은 누가 해제해요 | 라이브러리가 돌려주는 모든 `char *`는 `zeropty_free`로 정확히 한 번 해제해요. `free()`로 해제하지 않아요. 예외는 `zeropty_version`이에요. 정적 문자열이에요. | | 스레드 | `zeropty_open`이면 명령은 어느 스레드에서 와도 되고, 각 명령은 부른 쪽 스레드에서 돌아요. `zeropty_open_polled`면 모든 호출이 루프를 돌리는 한 스레드에서 와요. | | 닫은 뒤 | `zeropty_close` 뒤에는 핸들이 무효예요. 그 도중이나 뒤에 핸들로 어떤 호출도 돌면 안 돼요. | ## 버전 ### `ZEROPTY_VERSION_*` ```c #define ZEROPTY_VERSION_MAJOR 0 #define ZEROPTY_VERSION_MINOR 1 #define ZEROPTY_VERSION_PATCH 5 #define ZEROPTY_VERSION_PRERELEASE "" #define ZEROPTY_VERSION_STRING "0.1.6" #define ZEROPTY_VERSION_NUMBER (ZEROPTY_VERSION_MAJOR * 1000000 + ZEROPTY_VERSION_MINOR * 1000 + ZEROPTY_VERSION_PATCH) #define ZEROPTY_VERSION_IS_PRERELEASE 0 ``` 이 헤더가 속한 버전이에요. [버전 정책](/ko/guide/versioning)을 따라요. 위 값은 현재 릴리스의 값이에요. | 매크로 | 뜻 | |---|---| | `ZEROPTY_VERSION_MAJOR`, `ZEROPTY_VERSION_MINOR`, `ZEROPTY_VERSION_PATCH` | 세 숫자예요. | | `ZEROPTY_VERSION_PRERELEASE` | 프리릴리스 부분(0.2.0-rc.1의 `"rc.1"`), 릴리스면 `""`예요. | | `ZEROPTY_VERSION_STRING` | 버전 전체를 문자열로요. | | `ZEROPTY_VERSION_NUMBER` | `#if`용 숫자 하나: MAJOR × 1000000 + MINOR × 1000 + PATCH. 프리릴리스는 빠지므로 0.2.0-rc.1과 0.2.0은 숫자가 같아요. | | `ZEROPTY_VERSION_IS_PRERELEASE` | 프리릴리스면 `1`, 릴리스면 `0`. `#if`로 검사할 수 있어요. | 실행할 때 헤더와 라이브러리를 비교하세요. MAJOR가 다른(1.0 전에는 MINOR가 다른) 라이브러리는 이 선언과 맞지 않을 수 있어요. ### `zeropty_version` ```c const char *zeropty_version(void); ``` 링크된 라이브러리의 버전이에요. `ZEROPTY_VERSION_STRING`과 같은 형태예요. 정적 문자열이라 해제하지 마세요. ## 자기 스레드를 가진 엔진 ### `zeropty` ```c typedef struct zeropty zeropty; ``` 엔진 핸들이에요. 불투명 타입이라 이 페이지의 함수로만 써요. ### `zeropty_open` ```c zeropty *zeropty_open(const char *options_json); ``` 이 프로세스 안에서 엔진을 시작해요. | 인자 | 뜻 | |---|---| | `options_json` | `NULL`, 또는 `{"history": ROWS, "size_dir": "DIR"}`: 세션마다 스크롤백 줄 수, 크기를 바꾼 세션의 창 크기를 그 프로그램을 위해 써 두는 곳이에요. | 핸들을 돌려주고, 실패하면 `NULL`이에요. `zeropty_close`로 닫아요. ```c zeropty *zp = zeropty_open("{\"history\": 2000}"); if (!zp) return 1; ``` ### `zeropty_command` ```c char *zeropty_command(zeropty *a, const char *request_json); ``` CLI처럼 명령 하나를 실행해요. | 인자 | 뜻 | |---|---| | `a` | 엔진이에요. | | `request_json` | `{"argv": ["capture-pane", "-p", "-t", "=x:"], "cwd": "/"}`. | 답 `{"code", "out", "err"}`를 돌려줘요. 유효한 엔진이면 `NULL`은 없어요. `zeropty_free`로 해제해요. 플러그인의 명령은 플러그인을 기다려요. 스레드를 잡지 않고 기다리려면 [`zeropty_command_start`](#zeropty_command_start)를 써요. ### `zeropty_free` ```c void zeropty_free(char *reply); ``` 이 라이브러리의 답을 해제해요. 각각 정확히 한 번, `free()`로는 해제하지 않아요. `NULL`이면 아무것도 하지 않아요. ### `zeropty_listen` ```c int zeropty_listen(zeropty *a, const char *path); ``` zeropty 서버처럼 `path`의 유닉스 소켓에서도 명령에 답해요. 그러면 `ZEROPTY_SOCK=PATH zeropty send-keys ...`, `attach`, 소켓 클라이언트가 서버 프로세스 없이 이 엔진의 세션에 닿아요. `0`을 돌려주고, 거기서 listen할 수 없으면(이미 서버가 답하고 있거나, 경로가 너무 길거나, ...) `-1`이에요. polled 엔진은 `zeropty_process`에서 소켓을 처리해요. ### `zeropty_close` ```c void zeropty_close(zeropty *a); ``` 엔진을 멈춰요. 세션은 프로그램이 끝날 때까지 계속 돌아요. 세션을 끝내려면 먼저 `kill-server`를 보내요. 그 뒤로 핸들은 무효이고, `zeropty_close` 도중이나 뒤에 핸들로 어떤 호출도 돌면 안 돼요. ## 내 이벤트 루프가 돌리는 엔진 이 네 함수는 자기 스레드가 없는 엔진을 줘요. 내 루프가 엔진을 돌려요. 모든 호출은 그 한 스레드에서 와요. ```c zeropty *a = zeropty_open_polled(NULL); for (;;) { poll({zeropty_fd(a), POLLIN}, zeropty_timeout_ms(a)); zeropty_process(a); } ``` ### `zeropty_open_polled` ```c zeropty *zeropty_open_polled(const char *options_json); ``` [`zeropty_open`](#zeropty_open)과 같고 `options_json`도 같지만, 엔진에 스레드가 없어요. 핸들의 다른 호출(`zeropty_command`, `zeropty_listen`, `zeropty_close`, ...)은 루프의 스레드에서 전과 똑같이 동작해요. ### `zeropty_fd` ```c int zeropty_fd(zeropty *a); ``` 읽기를 기다릴 파일 디스크립터예요. 읽을 수 있게 되면 `zeropty_process`를 불러요. 자기 스레드를 가진 엔진이면 `-1`이에요. ### `zeropty_timeout_ms` ```c long long zeropty_timeout_ms(zeropty *a); ``` `zeropty_fd`를 읽을 수 없어도 `zeropty_process`를 불러야 할 때까지 남은 밀리초예요. ### `zeropty_process` ```c void zeropty_process(zeropty *a); ``` 기다리고 있는 일을 블로킹 없이 처리해요. `zeropty_fd`를 읽을 수 있거나 `zeropty_timeout_ms` 밀리초가 지나면 불러요. ## 플러그인을 기다리지 않는 명령 플러그인의 명령은 시간이 걸릴 수 있어요. 이 호출들은 명령을 시작하고 답이 올 때까지 루프가 계속 돌게 해 줘요. 두 종류의 엔진 모두에서 동작해요. ```c zeropty_call *c = zeropty_command_start(a, "{\"argv\":[\"my-plugin-cmd\"],\"cwd\":\"/\"}"); for (;;) { poll({zeropty_fd(a), POLLIN}, zeropty_timeout_ms(a)); zeropty_process(a); char *r = zeropty_call_poll(c); if (r) { ...; zeropty_free(r); break; } } zeropty_call_free(c); ``` 플러그인의 답이 `zeropty_fd`를 읽을 수 있게 만들고, `zeropty_timeout_ms`가 호출의 마감을 셈에 넣어서, 루프가 헛돌지 않아요. ### `zeropty_call` ```c typedef struct zeropty_call zeropty_call; ``` 진행 중인 명령이에요. 불투명 타입이에요. ### `zeropty_command_start` ```c zeropty_call *zeropty_command_start(zeropty *a, const char *request_json); ``` [`zeropty_command`](#zeropty_command)와 같은 `request_json`으로 명령을 시작해요. 플러그인의 명령은 진행 중인 상태로 돌아오고, 그 밖의 명령(과 잘못된 요청)은 이미 답이 들어 있어요. 호출은 `zeropty_call_free`로 해제해요. ### `zeropty_call_poll` ```c char *zeropty_call_poll(zeropty_call *c); ``` 호출이 기다리는 중이면 `NULL`이에요. 아니면 답 JSON을 한 번 줘요. 플러그인의 답, 또는 플러그인이 멈췄거나 마감이 지났을 때의 실패예요. `zeropty_free`로 해제해요. ### `zeropty_call_due_ms` ```c long long zeropty_call_due_ms(zeropty_call *c); ``` 호출의 마감, 곧 `zeropty_call_poll`이 플러그인을 포기할 때까지 남은 밀리초예요. `NULL`이면 `-1`이에요. ### `zeropty_call_free` ```c void zeropty_call_free(zeropty_call *c); ``` 호출을 해제해요. 답 전에 해제하면, 플러그인이 아직 시작하지 않은 호출은 실행되지 않아요. ## 이 프로세스 안의 플러그인 C 함수가 이 엔진의 세션을 듣고, 자기가 더한 명령에 답할 수 있어요. 작성 방법은 [플러그인 작성하기](/ko/guide/plugins)를, 메시지와 권한 규칙은 [플러그인 API](/ko/reference/plugin-api)를 보세요. ### `zeropty_host` ```c typedef struct zeropty_host zeropty_host; ``` 플러그인의 콜백이 명령을 실행할 때 쓰는 거예요. 넘겨받은 그 호출 동안만 유효해요. ### `zeropty_plugin_fn` ```c typedef char *(*zeropty_plugin_fn)(void *user, const char *message_json, zeropty_host *host); ``` 플러그인의 콜백이에요. 엔진 스레드가 아닌 플러그인 전용 스레드에서 돌고, 매번 JSON 메시지 하나를 받아요. | 메시지 | 언제 | |---|---| | `{"start":{"api":1}}` | 맨 처음. | | `{"event":"session-exited","session":"w","reason":"killed"}` 및 다른 이벤트 | 매니페스트가 요청한 이벤트마다. | | `{"call":1,"argv":["hello","x"],"cwd":"/"}` | 자기 명령 하나가 불렸을 때. | | `{"stop":true}` | 맨 마지막. | `call`이면 답 `{"code":0,"out":"...","err":""}`를 `malloc`한 메모리로 돌려주고(zeropty가 해제해요), 그 밖에는 `NULL`을 돌려줘요. 마감(10초)을 넘겨 멈춘 콜백은 잘리고, 느린 콜백은 이벤트를 잃어요. 엔진은 콜백 때문에 멈추지 않아요. ### `zeropty_add_plugin` ```c char *zeropty_add_plugin(zeropty *a, const char *manifest_json, zeropty_plugin_fn callback, void *user); ``` 이 엔진에 플러그인을 더해요. | 인자 | 뜻 | |---|---| | `manifest_json` | `{"name":"hello","events":["session-created","output"],"commands":["hello"],"access":["input"],"api":1}`. `events`는 목록이나 쉼표로 구분한 문자열(`"session-created,output"`)이에요. `access`는 `zeropty_host_command`가 실행할 수 있는 범위예요: 기본 `"read"`, `"input"`, `"manage"`, `"call"`, `"all"`. 빼도 돼요. | | `callback` | [`zeropty_plugin_fn`](#zeropty_plugin_fn)이에요. | | `user` | `callback`을 부를 때마다 다시 넘겨줘요. | 더해지면 `NULL`, 아니면 이유를 돌려줘요. `zeropty_free`로 해제해요. ### `zeropty_host_command` ```c char *zeropty_host_command(zeropty_host *host, const char *request_json); ``` 콜백 안에서 [`zeropty_command`](#zeropty_command)와 같은 `request_json`으로 명령을 실행해요. 엔진 루프를 거쳐 실행되어서 두 종류의 엔진 모두에서 안전해요. 답 JSON을 돌려주고, `zeropty_free`로 해제해요. ## 플러그인 라이브러리 플러그인 라이브러리는 이 헤더 하나만 가지고(`-lzeropty_c` 없이) 빌드한 `.dylib`이고, 진입점은 하나예요. ```c int zeropty_plugin_init(const zeropty_plugin_api *api, zeropty_plugin_def *def); ``` `def`를 채우고 `0`을 돌려주고(그 밖의 값이면 불러오지 않아요), `api`는 보관해 둬요. 같은 라이브러리가 서버에 붙은 별도 프로세스(`zeropty plugin-run plugin.dylib`, 또는 `ZEROPTY_PLUGIN_DIR`로 시작한 서버의 플러그인 디렉터리)에서 돌 수도 있고, [`zeropty_load_plugin`](#zeropty_load_plugin)으로 이 프로세스 안에서 돌 수도 있어요. ### `ZEROPTY_PLUGIN_API` ```c #define ZEROPTY_PLUGIN_API 1 ``` 이 헤더가 설명하는 플러그인 API 버전이에요. ### `zeropty_plugin_api` ```c typedef struct zeropty_plugin_api { unsigned int api; unsigned int size; char *(*host_command)(zeropty_host *host, const char *request_json); void (*free)(char *s); } zeropty_plugin_api; ``` | 필드 | 뜻 | |---|---| | `api` | 호스트의 플러그인 API 버전이에요. | | `size` | 호스트가 넘긴 것의 `sizeof`예요. 나중 버전은 끝에만 필드를 더해요. | | `host_command` | 이 플러그인용 [`zeropty_host_command`](#zeropty_host_command)예요. | | `free` | `host_command`가 돌려준 것을 해제해요. | ### `zeropty_plugin_def` ```c typedef struct zeropty_plugin_def { const char *manifest_json; zeropty_plugin_fn callback; void *user; } zeropty_plugin_def; ``` | 필드 | 뜻 | |---|---| | `manifest_json` | [`zeropty_add_plugin`](#zeropty_add_plugin)과 같아요. 라이브러리가 불려 있는 동안 유효해야 해요. | | `callback` | 플러그인의 [`zeropty_plugin_fn`](#zeropty_plugin_fn)이에요. | | `user` | `callback`을 부를 때마다 다시 넘겨줘요. | ### `zeropty_plugin_init_fn` ```c typedef int (*zeropty_plugin_init_fn)(const zeropty_plugin_api *api, zeropty_plugin_def *def); ``` 진입점 `zeropty_plugin_init`의 타입이에요. ### `zeropty_load_plugin` ```c char *zeropty_load_plugin(zeropty *a, const char *path); ``` 플러그인 라이브러리를 이 엔진의 프로세스에 불러와요. 라이브러리의 크래시는 이 프로세스의 크래시예요. 더해지면 `NULL`, 아니면 이유를 돌려줘요. `zeropty_free`로 해제해요. --- <!-- https://zeropty.pubdoc.dev/ko/reference/commands --> # 명령 `zeropty` CLI 의 모든 명령과 옵션이에요. 같은 명령이 소켓과 임베딩 API 에서도 그대로 돼요. ## CLI 빠르게 보기 ```sh zeropty new-session -d -s work -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" # 서버가 없으면 띄워요 zeropty capture-pane -p -e -S -200 -t '=work:' # 화면 + 스크롤백 200줄, 색 포함 zeropty wait-pane -t '=work:' -m '>' -q 1000 -T 60000 # '>'가 보이고 1초 조용할 때까지 (최대 60초) zeropty send-keys -t '=work:' -l 'hello' && zeropty send-keys -t '=work:' Enter zeropty attach -t work # 사람이 붙어서 보기. Ctrl-b d로 떼기 zeropty attach -r -t work # 읽기 전용 (q 또는 Ctrl-c로 떼기) zeropty kill-session -t '=work:' ``` **도움말과 버전.** `zeropty --help`(또는 `-h`, `help`)는 명령과 옵션 목록을, `zeropty help <명령>`은 그 명령의 사용법과 하는 일, 이 문서의 해당 절 링크를 표준 출력에 내고 종료 코드 0으로 끝나요. 줄임말(`help send`)도 돼요. 프로그램이 직접 답해서 서버가 없어도 되고, 서버를 띄우지도 않아요. zsh 함수와 bash builtin에서도 같아요. `zeropty -V`(또는 `--version`)는 CLI 자신의 버전을 출력해요(실행 중인 서버의 버전은 `zeropty display -p '#{version}'`). 무엇이 호환 범위인지는 [버전 정책](/ko/guide/versioning), 바뀐 내용은 [Changelog](/ko/guide/changelog)에 있어요. 옵션별 설명은 아래 [명령 레퍼런스](/ko/reference/commands#명령-레퍼런스)에 있어요. ```sh zeropty --help # 명령 목록 (종료 코드 0) zeropty help send-keys # 명령 하나: 사용법, 하는 일, 문서 링크 (없는 명령이면 종료 코드 2) zeropty -V # zeropty X.Y.Z (--version도 같아요) zeropty # usage: zeropty serve | <tmux command> ... (표준 에러, 종료 코드 2) ``` ## 명령 레퍼런스 괄호 안은 tmux와 같은 줄임말이에요. 옵션 뒤의 `<값>`은 값을 받는 옵션이에요. ### 세션 | 명령 | 옵션 | 하는 일 | |---|---|---| | `new-session` (`new`) `[명령]` | `-d` `-s <이름>` `-x <열>` `-y <행>` `-c <폴더>` `-e <변수=값>` `-A` `-P` `-F <포맷>` | 세션을 만들어요. 명령이 없으면 `$SHELL`(없으면 `/bin/sh`)이에요. 크기 기본값은 80×24이고, 이름이 없으면 지금까지 만든 세션 수(`0`, `1`, ...)를 써요. `-A`는 같은 이름이 있으면 그대로 두고 성공해요. `-P`는 새 세션 정보를 출력해요(`-F`로 형식 지정, 기본 `이름:`). `-e`는 여러 번 쓸 수 있어요. `-n -E -D -X`는 창·클라이언트용이라 받아만 둬요 | | `has-session` (`has`) | `-t` | 세션이 있으면 0, 없으면 1 (`can't find session: 이름`) | | `kill-session` | `-t` `-a` | 세션의 프로세스 그룹에 SIGHUP을 보내고, 2초 뒤에도 남은 프로세스는 SIGKILL해요. `-a`는 `-t` 세션을 뺀 나머지 전부예요. `-C`는 받아만 둬요 | | `list-sessions` (`ls`) | `-F <포맷>` | 세션 목록. 기본 출력은 tmux와 같은 `이름: 1 windows (created ...)`예요 | | `rename-session` (`rename`) `<새 이름>` | `-t` | 이름을 바꿔요. 빈 이름, `:`·`.`이 든 이름, 이미 있는 이름은 오류예요 | | `resize-window` (`resizew`), `resize-pane` (`resizep`) | `-t` `-x <열>` `-y <행>` | 화면 크기를 바꿔요. 프로그램은 SIGWINCH를 받아요 | | `pipe-pane` (`pipep`) `[명령]` | `-t` `-o` `-O` | 세션 출력을 명령의 입력으로 계속 보내요(tmux의 `-O`가 기본). 명령 없이 부르면 닫혀요. `-o`는 이미 열려 있으면 닫기만 해요. 명령이 못 받는 출력은 8MB까지 기다리고 넘치면 버려요. 세션을 다른 프로세스로 옮기면 닫혀요 | | `kill-server` | | 모든 세션에 SIGHUP을 보내요. 독립 서버는 프로세스도 끝나요. 임베디드 엔진은 세션만 끝나고 계속 돌아요 | 프로그램이 끝나면 세션도 끝나요(tmux의 `remain-on-exit` 꺼짐과 같아요). 서버는 자신을 띄운 프로세스(서버를 띄우는 첫 명령: `new-session`, `pull-sessions`, `zeropty serve`)의 환경을 그대로 가져요(tmux와 같아요). 세션은 `new-session`을 실행한 셸의 환경이 아니라 그 환경에 `set-environment -g`와 `-e`를 적용한 것을 받아요. `restart-server`는 그 명령을 실행한 셸에서 새 서버를 띄워서, 이후 세션은 그 셸의 환경을 받고 `set-environment -g`로 넣은 값은 사라져요(다시 넣어야 해요). 실행 중인 세션은 자기 환경을 그대로 가져요. 세션 안의 프로그램이 받는 환경: `TERM=xterm-256color`, `COLUMNS`/`LINES`, `ZEROPTY_SESSION`(세션 이름), `ZEROPTY_COLS`/`ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE`(shim이 읽는 창 크기 파일), `ZEROPTY_TTY_ID`(어느 파일이 세션의 터미널인지, shim용). `TMUX`, `TMUX_PANE`은 지워요(tmux 안에서 서버를 띄웠어도 세션은 tmux 안이 아니에요). ### 키 보내기 | 명령 | 옵션 | 하는 일 | |---|---|---| | `send-keys` (`send`) `<키>...` | `-t` `-l` `-H` `-N <횟수>` | 키를 보내요. 인자마다 [키 이름](/ko/reference/keys-and-formats#키-이름)이면 그 키의 바이트를, 아니면 글자 그대로를 보내요. `-l`은 키 이름을 해석하지 않고 글자 그대로 보내요. `-H`는 인자를 16진수 바이트로 읽어요(`-H 41 42 0a`는 `AB` + 줄바꿈). `-N`은 전체를 그 횟수만큼 반복해요 | | `paste-buffer` (`pasteb`) | `-t` `-b <버퍼>` `-d` `-p` `-r` `-s <구분자>` | 버퍼 내용을 키처럼 보내요. 줄바꿈은 `\r`로 바꿔 보내요(`-r`이면 그대로, `-s`로 구분자 지정). `-p`는 프로그램이 bracketed paste를 켰으면 감싸서 보내요. `-d`는 보낸 뒤 버퍼를 지워요 | 키는 PTY처럼 [라인 디시플린](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/USAGE.md#키-입력과-라인-디시플린)을 거쳐요. ### 화면 읽기 | 명령 | 옵션 | 하는 일 | |---|---|---| | `capture-pane` (`capturep`) | `-t` `-p` `-e` `-J` `-N` `-S <줄>` `-E <줄>` `-b <버퍼>` `-q` | 화면을 텍스트로 읽어요. 읽기 직전에 밀린 출력부터 처리해서 늘 최신 화면이에요. `-e`는 색·글꼴 이스케이프 포함, `-J`는 줄바꿈으로 나뉜 줄을 이어 붙이고, `-N`은 줄 끝 공백을 남겨요. `-S`/`-E`는 tmux와 같은 줄 번호예요(0이 화면 첫 줄, 음수가 스크롤백, `-`는 끝까지). `-p`가 없으면 tmux처럼 화면을 버퍼(`-b`, 없으면 `buffer0`)에 넣고 아무것도 출력하지 않아요 | | `display-message` (`display`) `[포맷]` | `-t` `-p` `-F <포맷>` | [포맷 변수](/ko/reference/keys-and-formats#포맷-변수)를 채워서 출력해요. 예: `display-message -p -t '=work:' '#{cursor_x},#{cursor_y}'` | ### 기다리기와 찾기 tmux에는 없는 zeropty 명령이에요. 에이전트가 끝났는지, 어느 세션이 입력을 기다리는지를 폴링 없이 알아요. | 명령 | 옵션 | 하는 일 | |---|---|---| | `wait-pane` | `-t` `-m <글자>` `-q <ms>` `-T <ms>` | `-m`은 그 글자가 화면에 나타날 때까지(이미 있으면 바로), `-q`는 세션이 그 시간 동안 아무것도 출력하지 않을 때까지 기다려요. 둘 다 주면 둘 다 만족할 때 답해요. `-T`가 지나면 종료 코드 1(`wait-pane: timed out`), 기다리는 중에 세션이 끝나도 1(`wait-pane: session ended`)이에요 | | `grep-panes <글자>` | `-S <줄>` | 모든 세션의 화면에서 글자를 찾아 `세션: 줄`로 보여요. `-S`는 `capture-pane -S`처럼 스크롤백까지 찾아요(`-S -100`). 세션 이름순이고, 없으면 종료 코드 1이에요(grep처럼) | - `wait-pane`은 서버가 출력이 올 때만, 그 출력이 바꾼 행만 확인해요. 글자는 행 단위로 찾아요(줄바꿈으로 나뉜 글자는 못 찾아요). - 같은 연결의 다음 명령은 `wait-pane`의 답 뒤에 처리돼요. `client/zeropty.mjs`는 `wait-pane`마다 연결을 따로 열어서 다른 명령이 막히지 않아요. - 세션이 마지막 출력을 내자마자 끝나면, 엔진이 그 출력을 확인하기 전에 세션 종료를 먼저 알려 `wait-pane: session ended`(종료 코드 1)로 끝날 수 있어요(알려진 문제). 기다리는 글자가 나온 뒤에도 세션이 살아 있게 하세요. 예: `sh -c 'make; echo build done; exec cat'` - 임베디드 엔진의 함수 호출로는 `wait-pane`을 쓸 수 없어요(답을 미룰 루프가 없어서). [서버 플러그인](/ko/reference/rust-api#서버-플러그인-내-프로그램이-소켓도-열기)을 켜면 소켓으로는 돼요. Rust 호스트는 `Engine::wait_start`·`wait_check`를 직접 써요([`examples/polled.rs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/polled.rs)). ### 버퍼 | 명령 | 옵션 | 하는 일 | |---|---|---| | `load-buffer` (`loadb`) `<파일>` | `-b <버퍼>` | 파일 내용을 버퍼에 넣어요(기본 `buffer0`). 상대 경로는 명령을 보낸 쪽의 현재 폴더 기준이에요 | | `paste-buffer` | | [키 보내기](/ko/reference/commands#키-보내기)를 보세요 | | `capture-pane -b` | | `-p` 없이 쓰면 화면을 버퍼에 넣어요 | ### 보기 (attach) | 명령 | 옵션 | 하는 일 | |---|---|---| | `attach-session` (`attach`, `a`) | `-t` `-r` | 지금 터미널로 세션에 붙어요. `Ctrl-b d`로 떼요. `-r`은 읽기 전용이고 `q`나 `Ctrl-c`로 떼요. `-t`가 없으면 세션이 하나일 때 그 세션에, 여럿이면 번호를 물어봐요 | 붙어 있는 동안 세션은 내 터미널 크기를 따르고, 떼면 원래 크기로 돌아와요. 여러 터미널이 한 세션에 붙을 수 있고, 크기는 마지막에 붙은 터미널을 따라요. ### 환경 | 명령 | 옵션 | 하는 일 | |---|---|---| | `set-environment` (`setenv`) `<변수> [값]` | `-g` `-u` | 이후에 만드는 세션에 줄 환경변수를 정해요. `-u`는 지워요. `-g` 없이는 tmux에서도 새 창에만 적용되는데, 세션에 새 창이 생기지 않으니 아무 일도 하지 않아요 | | `show-environment` (`showenv`) | `-g` | `-g`로 정한 변수를 보여요. 지운 변수는 `-변수`로 보여요 | ### 서버와 세션 옮기기 | 명령 | 옵션 | 하는 일 | |---|---|---| | `serve` | `--bind <소켓>` | 독립 서버를 직접 띄워요(보통은 `new-session`이 알아서 띄워요) | | `pull-sessions` `[이름...]` | `-s <소켓>` | 다른 서버의 세션을 이 서버로 가져와요(이름 없으면 전부) | | `push-sessions` `[이름...]` | `-s <소켓>` | 이 서버의 세션을 다른 서버로 보내요 | | `restart-server` | | 새 서버가 세션을 전부 넘겨받고 소켓 자리를 차지해요. 업그레이드에 써요 | 자세한 동작: [세션 옮기기](/ko/reference/socket-protocol#세션-옮기기). 서버끼리 쓰는 내부 요청 `export-sessions`, `import-sessions`: [소켓 프로토콜](/ko/reference/socket-protocol#소켓-프로토콜-export-sessions-import-sessions) ### 플러그인 | 명령 | 옵션 | 하는 일 | |---|---|---| | `list-plugins` | | 붙은 플러그인마다 한 줄: `이름: 상태 (api N) events=... commands=... access=... dropped=N`. 상태는 `running`, `stopped`, `failed (이유)`예요 | | `plugin-attach` | | 소켓 연결을 플러그인으로 바꿔요. 소켓에서만 의미가 있어요 | | `plugin-run` `<라이브러리.dylib>` | | C 플러그인 라이브러리를 별도 프로세스로 불러와 서버에 붙여요. 서버가 끝나면 같이 끝나요 | 플러그인이 더한 명령은 내장 명령처럼 CLI·소켓·임베디드에서 불러요. `ZEROPTY_PLUGIN_DIR`를 지정하면 `zeropty serve`가 그 디렉터리의 플러그인을 띄우고 끝나면 다시 띄워요. **지정하지 않으면(기본) 아무것도 실행하지 않아요.** 디렉터리 지정이 그 안의 코드를 서버와 같은 사용자로 실행해도 된다는 동의라서 기본은 꺼 뒀어요(이 기능 전과 같은 동작). 붙이는 법과 프로토콜: [플러그인 API](/ko/reference/plugin-api) ### 진단 | 명령 | 하는 일 | |---|---| | `stats` | 엔진 카운터 한 줄: `sessions`, `heap_bytes`(할당기에 요청한 바이트), `screen_bytes`, `live`/`frozen` 화면 수, 읽기 횟수 등 | | `mstats` | 할당기(mimalloc) 통계. 독립 서버에만 있어요(그 프로세스의 할당기를 세는 명령이라서요) | | `mem -t <세션>` | 화면 하나의 메모리 내역. 얼린 화면이면 `frozen <바이트> B deep <true/false>` | --- <!-- https://zeropty.pubdoc.dev/ko/reference/environment --> # 환경변수 zeropty 가 읽는 모든 환경변수예요. ## 환경변수 | 변수 | 기본값 | 의미 | |---|---|---| | `ZEROPTY_SOCK` | `~/.zeropty/mux.sock` | 서버 소켓 (경로는 103바이트 이하) | | `ZEROPTY_HISTORY` | 500 | 스크롤백 줄 수 | | `ZEROPTY_COALESCE_MS` | 300 | 출력을 묶어 읽는 간격. 16KB 이상 쌓이면 즉시 읽고, 화면 읽기는 항상 밀린 출력부터 읽어요. 붙어 있는 터미널에는 바로 보내요 | | `ZEROPTY_FREEZE_MS` | 250 | 이 시간만큼 조용하면 화면을 통째로 얼림 | | `ZEROPTY_COLD` | 파일 | 오래 조용한 화면의 압축본을 둘 곳. `memory`면 프로세스 메모리에 | | `ZEROPTY_COLD_DIR` | 임시 폴더 | 압축본 파일을 만들 폴더 (만들자마자 이름을 지워요) | | `ZEROPTY_RECORD` | – | 세션 출력을 `<이름>.raw`/`.trace`로 기록할 폴더 | | `ZEROPTY_PURGE_MS` | 0 | 독립 서버: 해제한 메모리를 OS에 돌려주기 전 기다리는 시간 | | `ZEROPTY_BIN` | `dist/zeropty` | Node 클라이언트·zsh 함수·bash builtin이 실행할 프로그램 | | `ZEROPTY_NODE` | `dist/zeropty.node` | Node 클라이언트가 불러올 네이티브 모듈 | | `ZEROPTY_SHIM` | `dist/shim.dylib` | `zeropty-tty`가 넣을 shim | | `ZEROPTY_CACHE` | `~/.zeropty` | `zeropty-tty`가 서명을 바꾼 사본을 둘 곳(`tty/` 아래) | | `ZEROPTY_CLAUDE_REAL` | `PATH`의 `claude` | `claude-zeropty`가 띄울 claude | | `ZEROPTY_PLUGIN_DIR` | (없음: 끔) | 서버가 띄울 플러그인 디렉터리. 지정할 때만 그 안의 실행 파일과 `.dylib`를 실행해요. `restart-server`·`new-session`이 띄우는 서버는 그 명령을 실행한 셸의 값을 받아요 ([PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#자동-시작-플러그인-디렉터리)) | | `ZEROPTY_PLUGIN_TIMEOUT_MS` | 10000 | 플러그인 명령과 훅의 마감 시간. 플러그인의 `Host::command`는 그 절반까지 기다려요 | --- <!-- https://zeropty.pubdoc.dev/ko/reference/ --> # 레퍼런스 모든 명령, 옵션, 프로토콜, API 를 빠짐없이 정확하게 적어요. 레퍼런스는 각각이 무엇인지 말해요. 단계와 예제로 쓰는 방법은 [가이드](/ko/guide/introduction)를 보세요. 명령줄 페이지와 소켓·플러그인 프로토콜은 사이트를 빌드할 때마다 저장소의 `docs/` 에서 만들어서, 언제나 `main` 의 코드와 같아요. C ABI 와 Node.js 페이지는 빌드가 내놓는 공개 기호 목록(`api/*.txt`)과 대조해 검사해요. ## 명령줄 | 페이지 | 다루는 것 | |---|---| | [명령](/ko/reference/commands) | 모든 명령과 옵션. 세션, 키 보내기, 화면 읽기, 기다리기와 찾기, 버퍼, 보기, 환경, 서버, 플러그인, 진단으로 묶었어요. | | [키, 포맷, 종료 코드](/ko/reference/keys-and-formats) | `send-keys` 의 키 이름, `-F`·`display-message` 의 포맷 변수, 종료 코드. | | [환경변수](/ko/reference/environment) | zeropty 가 읽는 모든 변수와 기본값. | | [tmux 호환](/ko/reference/tmux-compatibility) | tmux 와 비교해 zeropty 가 하는 것과 하지 않는 것. | ## 프로그램에서 | 페이지 | 다루는 것 | |---|---| | [소켓 프로토콜](/ko/reference/socket-protocol) | 서버 소켓의 JSON 줄과, 서버 사이에 세션 옮기기. | | [C ABI](/ko/reference/c-abi) | `libzeropty_c.dylib` 과 `zeropty.h` 의 모든 함수, 타입, 매크로. | | [Node.js API](/ko/reference/node-api) | `client/zeropty.mjs`, `client/embedded.mjs`, `zeropty.node` 가 내보내는 모든 것. | | [Rust API](/ko/reference/rust-api) | Rust 크레이트. 내 프로세스 안의 엔진, 서버 플러그인으로 쓰기, Rust 플러그인, rustdoc 만드는 법. | | [플러그인 API](/ko/reference/plugin-api) | 권한, 플러그인 소켓 프로토콜, 이벤트, 명령, `zeropty::plugin` 의 Rust API. | ## 그 밖에 | 어디 | 무엇 | |---|---| | [가이드](/ko/guide/introduction) | 개념과 작업 가이드. | | [벤치마크](/ko/guide/benchmarks) | 모든 측정값과 잰 방법. | | [변경 기록](/ko/guide/changelog) | 판마다의 변경. | | [`llms.txt`](/llms.txt), [`llms-full.txt`](/llms-full.txt) | 이 사이트를 도구가 읽는 글로. | | `zeropty --help`, `zeropty help COMMAND` | 같은 명령 레퍼런스를 오프라인으로. | --- <!-- https://zeropty.pubdoc.dev/ko/reference/keys-and-formats --> # 키, 포맷, 종료 코드 `send-keys` 의 키 이름, `-F` 의 포맷 변수, 모든 명령의 종료 코드예요. ## 키 이름 `Enter`, `Escape`, `Tab`, `BTab`, `BSpace`, `Space`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp`(`PgUp`, `PPage`), `PageDown`(`PgDn`, `NPage`), `IC`(`Insert`), `DC`(`Delete`), `F1`\~`F12`. 앞에 `C-`(Ctrl), `M-`(Alt), `S-`(Shift)를 붙여 조합해요(`C-c`, `M-x`, `S-Up`, `^c`도 돼요). 이름은 대소문자를 가리지 않아요. tmux 3과 같은 바이트를 보내는지 대조했어요. 키 이름이 아닌 인자는 글자 그대로 가요. 프로그램이 application cursor 모드를 켜면(vim 등) 방향키는 `ESC O A` 형태로 바뀌어요. tmux와 같아요. ## 포맷 변수 `list-sessions -F`, `new-session -P -F`, `display-message`에서 써요. | 변수 | 뜻 | |---|---| | `session_name`, `session_id`, `session_created`, `session_attached`, `session_windows` | 세션 이름, `$`로 시작하는 id(`pane_id`는 `%`), 만든 시각(유닉스 초), 붙은 터미널 수, 창 수(늘 1) | | `window_index`, `window_id`, `window_name`, `window_width`, `window_height`, `window_panes` | 창 정보(창은 늘 하나) | | `pane_id`, `pane_index`, `pane_pid`, `pane_width`, `pane_height` | 패널 정보, 프로그램의 pid | | `pane_current_command`, `pane_current_path` | 지금 실행 중인 명령 이름과 폴더 | | `pane_dead`, `pane_in_mode` | 늘 `0`(tmux 스크립트 호환용. `window_index`, `pane_index`도 늘 `0`) | | `cursor_x`, `cursor_y`, `alternate_on` | 커서 위치(0부터), 대체 화면 사용 여부 | | `history_size`, `history_limit` | 스크롤백 줄 수와 한도 | | `version` | 답하는 서버(또는 임베디드 엔진)의 zeropty 버전 | tmux와 같은 세션에서 대조했어요. `#{version}`만 쓰는 포맷은 세션 없이도 돼요(`zeropty display -p '#{version}'`는 실행 중인 서버의 버전, `zeropty -V`는 CLI 자신의 버전). `-t`가 없는 세션을 가리키면 `display-message`는 tmux처럼 세션 변수를 비운 포맷을 출력하고 종료 코드 0이에요. 세션이 있는지 확인하려면 `has-session`을 쓰세요. 없는 변수는 tmux처럼 빈 문자열이고, 조건식(`#{?...}`)과 수식어(`#{t:...}`)는 안 돼요. ## 종료 코드 | 코드 | 뜻 | |---|---| | 0 | 성공 | | 1 | 실패: 없는 세션(`display-message -t`는 예외, [포맷 변수](/ko/reference/keys-and-formats#포맷-변수) 참고), 모르는 옵션, `wait-pane` 시간 초과·세션 종료, `grep-panes`·`has-session`의 "없음", 서버 없음(`no server running on ...`) | | 2 | 사용법 오류: 인자 없이 실행, `zeropty help <모르는 명령>`, `attach`의 모르는 인자 | --- <!-- https://zeropty.pubdoc.dev/ko/reference/node-api --> # Node.js API zeropty Node.js 모듈이 내보내는 모든 것이에요. 첫 프로그램과 예제는 [Node.js에서 zeropty 쓰기](/ko/guide/node)를 보세요. 빌드가 이 목록을 검사해요. `api/node.txt`를 모듈과 비교하고, 거기 있는 export가 이 페이지에 없으면 사이트 빌드가 실패해요. 지원하는 Node.js: N-API 6 이상(`napi6`). | 모듈 | 무엇인가요 | 언제 써요 | |---|---|---| | [`client/zeropty.mjs`](#clientzeroptymjs) | 실행 중인 zeropty 서버의 소켓에 붙는 클라이언트 | 세션이 zeropty 서버에 있고 CLI와 함께 써요. | | [`client/embedded.mjs`](#clientembeddedmjs) | 내 Node 프로세스 안의 엔진 | 내 프로그램이 서버 프로세스 없이 세션을 가져요. | | [`zeropty.node`](#zeroptynode) | 두 모듈이 함께 쓰는 네이티브 모듈 | JS 모듈이 감싸지 않은 클래스가 필요할 때요. | 두 JS 모듈은 같은 `zeropty`와 `zeroptySync`를 내보내서, 둘 사이를 바꿀 때는 import만 바꾸면 돼요. ## 결과 형태 ```ts { ok: boolean, code: number, out: string, err: string } ``` 명령의 결과로, CLI의 종료 상태와 출력 그대로예요. `code`는 종료 코드, `ok`는 `code === 0`, `out`과 `err`는 표준 출력과 표준 오류예요. ## `client/zeropty.mjs` zeropty 서버의 클라이언트예요. 서버 소켓과의 연결 하나를 열어 두고, 명령을 한 줄씩 보내면 답이 순서대로 돌아와요. argv가 CLI와 같아서 `tmux(...args)`를 부르던 곳은 `zeropty(args)`가 돼요. ### `zeropty` ```ts zeropty(argv: string[], options?: { cwd?: string, sock?: string, timeout?: number }): Promise<Result> ``` 명령 하나를 실행하고 그 [결과](#결과-형태)로 resolve해요. | 인자 | 기본값 | 뜻 | |---|---|---| | `argv` | | 명령 줄이에요. 명령 이름이 먼저 와요. | | `options.cwd` | `process.cwd()` | 명령이 보는 작업 디렉터리예요. | | `options.sock` | [`sockPath()`](#sockpath) | 서버의 소켓이에요. | | `options.timeout` | `15000` | 답을 기다리는 밀리초예요. `wait-pane`은 여기서 주지 않으면 타임아웃이 없어요(자기 `-T`는 서버 쪽 타임아웃을 정해요). | 서버가 아니라 프로그램이 직접 실행하는 명령(`restart-server`, `serve`, `attach`, `-V`)은 CLI를 실행해요. ### `zeroptySync` ```ts zeroptySync(argv: string[], options?: { cwd?: string, sock?: string }): Result ``` 같은 일을 동기로 해요. 명령을 쓰고 답을 이 스레드에서 블로킹으로 읽고, 그 사이에 이벤트 루프가 돌지 않아요. Node가 달리 할 일이 없을 때는 `zeropty`보다 빠르고, 명령 시간(약 0.1 ms) 동안 스레드를 잡아요. `dist/zeropty.node`가 필요해요. 서버가 없으면 CLI가 실행되고, `new-session`이면 서버를 시작해요. ### `sockPath` ```ts sockPath(): string ``` 서버의 소켓이에요. `$ZEROPTY_SOCK`, 없으면 `~/.zeropty/mux.sock`이에요. ### `cliPath` ```ts cliPath(): string ``` 서버가 실행할 수 없는 명령이 가는 CLI예요. `$ZEROPTY_BIN`, 없으면 모듈 옆의 `dist/zeropty`예요. ### `VERSION` ```ts VERSION: string ``` 이 모듈의 버전이에요. [버전 정책](/ko/guide/versioning)을 따라요. ## `client/embedded.mjs` 서버 프로세스 없이 내 프로세스 안에 있는 엔진이에요. 네이티브 모듈은 `$ZEROPTY_NODE`, 없으면 모듈 옆의 `dist/zeropty.node`에서 불러와요. ### `open` ```ts open(options?: { history?: number, sizeDir?: string, polled?: boolean }): Engine ``` 엔진을 지금 시작해요. 부르지 않으면 첫 명령이 시작해요. [`Engine`](#engine)을 돌려줘요. | 옵션 | 기본값 | 뜻 | |---|---|---| | `history` | | 세션마다 스크롤백 줄 수예요. | | `sizeDir` | | 크기를 바꾼 세션의 창 크기를 그 프로그램을 위해 써 두는 곳이에요. | | `polled` | `true` | 엔진에 자기 스레드 없이 Node의 이벤트 루프가 엔진을 돌려요. 약 0.3 MB 덜 쓰고, 출력은 JS 스레드에서 조금씩 나눠 파싱해요. `false`면 엔진에 스레드를 줘서 worker 스레드에서도 명령을 부를 수 있어요. | ### `zeropty` ```ts zeropty(argv: string[], options?: { cwd?: string }): Promise<Result> ``` 명령 하나를 실행하고 그 [결과](#결과-형태)로 resolve해요. 플러그인의 명령은 JS 스레드를 잡지 않고 기다려서, 그동안 다른 일과 `listen`으로 이 프로세스에 붙은 플러그인이 계속 돌아요. ### `zeroptySync` ```ts zeroptySync(argv: string[], options?: { cwd?: string }): Result ``` 같은 일을 바로 돌려줘요. 엔진 명령은 아무것도 기다리지 않아요. 플러그인의 명령은 답할 때까지, 마감까지 JS 스레드를 잡아요. 그런 명령에는 `zeropty`를 써요. ### `listen` ```ts listen(sockPath: string): void ``` zeropty 서버처럼 `sockPath`의 유닉스 소켓에서도 명령에 답해요. 그러면 zeropty CLI(`ZEROPTY_SOCK=SOCKPATH zeropty send-keys ...`), `attach`, `client/zeropty.mjs`가 서버 프로세스 없이 이 프로세스의 세션에 닿아요. 이미 서버가 거기서 답하고 있으면 예외를 던져요. ### `version` ```ts version(): string ``` 불러온 `zeropty.node`의 버전이에요. `VERSION`과 같아야 해요. 다른 체크아웃에서 빌드한 `dist/`면 다를 수 있어요. ### `VERSION` ```ts VERSION: string ``` 이 모듈의 버전이에요. ## `zeropty.node` 네이티브 모듈이에요. `createRequire`로 불러와요. ```js [app.mjs] import { createRequire } from 'node:module' const { Engine, Client, version } = createRequire(import.meta.url)('./dist/zeropty.node') ``` ### `version` ```ts version(): string ``` 엔진의 버전이에요. `zeropty --version`, C ABI의 [`zeropty_version()`](/ko/reference/c-abi#zeropty_version)과 같아요. ### `Engine` 이 프로세스 안의 zeropty 엔진이에요. `client/embedded.mjs`가 하나를 감싸요. ```ts new Engine(options?: { history?: number, sizeDir?: string, polled?: boolean }) ``` 세션이 없는 엔진이에요. `polled`가 `false`가 아니면 Node의 루프가 돌려요. 옵션은 [`open`](#open)과 같아요. ```js const engine = new Engine({ history: 2000 }) ``` | 메서드 | 돌려주는 값 | 뜻 | |---|---|---| | `command(argv: string[], cwd?: string)` | `{ code, out, err }` | 서버 왕복 없이 명령 하나를 동기로 실행해요(Node에서 `send-keys` p50 0.06–0.07 ms, [벤치마크](/ko/guide/benchmarks)에서 측정). 플러그인의 명령은 마감까지 기다려요. | | `commandStart(argv: string[], cwd?: string)` | `Promise<{ code, out, err }>` | 같은 일을 플러그인이 답하는 동안 JS 스레드를 잡지 않고 해요. 엔진 명령의 promise는 이미 resolve돼 있고, 플러그인 명령은 답이 오면 resolve돼요. | | `listen(path: string)` | | zeropty 서버처럼 `path`의 유닉스 소켓에서도 명령에 답해요. polled면 연결은 Node의 루프에서 처리해요. | ```js const { code, out } = engine.command(['capture-pane', '-p', '-t', '=w:']) engine.listen(`${process.env.HOME}/.zeropty/app.sock`) // then: ZEROPTY_SOCK=... zeropty attach -t w ``` ### `Client` zeropty 서버와의 연결로, 동기로 써요. 명령마다 쓰고 답을 부른 스레드에서 블로킹으로 읽고, 그 사이에 이벤트 루프가 돌지 않아요. `client/zeropty.mjs`의 `zeroptySync`가 하나를 써요. ```ts new Client(sock: string) ``` `sock`은 서버의 소켓 경로예요. 첫 명령에서 연결해요. | 메서드 | 돌려주는 값 | 뜻 | |---|---|---| | `command(argv: string[], cwd?: string)` | `{ code, out, err }` | 서버에서 명령 하나를 실행해요. 서버가 없거나 사라졌으면 예외를 던져요. | | `close()` | | 연결을 닫아요. 다음 명령이 새로 열어요. | ```js const c = new Client(`${process.env.HOME}/.zeropty/mux.sock`) console.log(c.command(['list-sessions']).out) c.close() ``` --- <!-- https://zeropty.pubdoc.dev/ko/reference/plugin-api --> # 플러그인 API 권한, 플러그인용 소켓 프로토콜, 이벤트, 명령, `zeropty::plugin` 의 Rust API 예요. ## 권한 (access) 플러그인이 `Host::command`(소켓은 `id` 명령, C는 `zeropty_host_command`)로 실행할 수 있는 명령은 플러그인이 등록할 때 밝힌 권한까지예요. 기본은 보기(`read`)뿐이에요. | 권한 | Rust | 허용하는 명령 | |---|---|---| | `read` (기본) | `Access::READ` | `capture-pane`, `display-message`, `has-session`, `list-sessions`, `grep-panes`, `show-environment`, `stats`, `mem`, `list-plugins` | | `input` | `Access::INPUT` | `send-keys`, `paste-buffer`, `load-buffer` | | `manage` | `Access::MANAGE` | `new-session`, `kill-session`, `rename-session`, `resize-*`, `set-environment`, `pipe-pane`(프로그램을 실행해요), 그 밖에 위 목록에 없는 명령 | | `call` | `Access::CALL` | 다른 플러그인이 더한 명령 | | `all` | `Access::ALL` | 위 전부 | - 밝히는 곳: Rust `Manifest::new("x").access(Access::INPUT)`, 소켓 `plugin-attach ... -a input,call`, C·라이브러리 매니페스트 `"access":["input"]`, Node `attachPlugin({ access: ['input'] })`. - 권한 밖의 명령은 실행하지 않고 `send-keys: plugin x lacks access input (...)`로 답해요. `list-plugins`의 `access=`에 각 플러그인의 권한이 보여요. - 어떤 권한으로도 안 되는 것: `kill-server`, `pull-sessions`, `push-sessions`(세션 넘기기는 엔진 스레드를 잡아요), `attach`, `plugin-attach`, 자기 자신의 명령. - 권한은 플러그인이 무엇을 하려는지 드러내고 실수를 막는 경계예요. 같은 사용자로 도는 프로세스 밖 플러그인은 소켓에 일반 클라이언트로 붙을 수도 있어서, 보안 경계는 소켓(이 사용자만 접근)이에요. ## 1. 다른 프로세스에서: 소켓 프로토콜 (언어 무관) `zeropty serve`든, `listen`을 부른 임베디드 엔진이든 소켓이 열려 있으면 붙을 수 있어요. 연결 하나가 플러그인 하나예요. 메시지는 모두 JSON 한 줄(끝에 `\n`)이에요. **1단계: 연결하기.** `ZEROPTY_SOCK`(없으면 `~/.zeropty/mux.sock`)에 유닉스 소켓으로 연결해요. CLI와 같은 규칙이에요. **2단계: 붙기.** 첫 명령으로 `plugin-attach`를 보내요. ```json {"argv":["plugin-attach","-n","hello","-e","session-created,session-exited","-c","hello"],"cwd":"/"} ``` | 옵션 | 뜻 | |---|---| | `-n 이름` | 플러그인 이름 (필수). 영문자, 숫자, `- _ . :`, 64자까지 | | `-e 목록` | 받을 이벤트, 쉼표로 구분. `sessions`(생성·종료·이름 변경), `all`도 돼요. 없으면 이벤트를 받지 않아요 | | `-c 목록` | 더할 명령 이름, 쉼표로 구분. 내장 명령 이름과 다른 플러그인이 가진 이름은 쓸 수 없어요 | | `-a 목록` | 권한(위 표), 쉼표로 구분. 없으면 `read` | | `-v 버전` | 이 플러그인이 쓰인 API 버전 (기본 1). zeropty보다 높으면 거절해요 | 답은 보통 명령 답과 같은 줄이에요. 성공하면 `{"code":0,"out":"zeropty-plugin 1\n","err":""}`이고, 이 답이 올 때는 이미 등록이 끝나 있어요. 실패하면 `code` 1과 이유예요(연결은 클라이언트로 남아서 고쳐서 다시 보내도 돼요). **3단계: 메시지 주고받기.** 붙은 뒤로는 이 연결로 아래 메시지가 오가요. 첫 키가 메시지 종류예요. | 방향 | 메시지 | 할 일 | |---|---|---| | zeropty → 플러그인 | `{"event":"session-created","session":"w"}` | 이벤트 (아래 표) | | zeropty → 플러그인 | `{"call":7,"argv":["hello","you"],"cwd":"/"}` | 더한 명령이 불렸어요. 같은 번호로 답해요 | | 플러그인 → zeropty | `{"reply":7,"code":0,"out":"hello\n","err":""}` | `call`에 대한 답 | | 플러그인 → zeropty | `{"id":3,"argv":["capture-pane","-p","-t","=w:"],"cwd":"/"}` | zeropty 명령 실행 (권한 안에서) | | zeropty → 플러그인 | `{"id":3,"code":0,"out":"...","err":""}` | 그 명령의 답 (보낸 순서대로) | - `call`에 답하는 중에 `id` 명령을 보내도 돼요(예: `hello.py`는 `list-sessions`로 세션 목록을 받아서 답해요). 그 답을 기다리는 동안 이벤트나 다른 `call`이 먼저 올 수 있으니, 받은 순서대로 쌓아 뒀다가 처리해요. - `wait-pane`은 플러그인에서 쓸 수 없어요. 기다리는 일은 이벤트로 해요. - 연결을 닫으면 플러그인이 등록부에서 빠지고, 그 명령은 다시 `unknown command`가 돼요. 서버가 끝나면 플러그인은 EOF를 받아요. - zeropty가 쓰는 소켓 쓰기에는 마감 시간(`reply_wait`, 마감의 8분의 7)이 있어요. 플러그인이 읽지 않고 버티면 그 플러그인만 실패 처리돼요. - `call`에는 마감의 8분의 7(기본 8.75초) 안에 답해요. 늦으면 그 호출만 `plugin timed out`으로 실패하고 늦은 `reply`는 버려져요. 플러그인은 계속 붙어 있어요. 오래 걸리는 일이면 먼저 "시작했다"고 답하고 결과는 다른 명령으로 가져가게 만드세요. **Python, 표준 라이브러리만:** [`examples/plugins/hello.py`](https://github.com/CreatiCoding/zeropty/blob/main/examples/plugins/hello.py)가 프로토콜 전체예요(68줄). ```sh python3 examples/plugins/hello.py & zeropty hello you ``` **Node:** [`client/plugin.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/client/plugin.mjs)의 `attachPlugin`이 연결, 줄 나누기, 번호 맞추기를 해 줘요. ```js import { attachPlugin } from './client/plugin.mjs' const plugin = await attachPlugin({ name: 'notes', events: ['session-exited'], access: [], // 보기만 해요. 키 입력이 필요하면 ['input'] onEvent: (ev) => console.log(`${ev.session} ${ev.reason}`), commands: { // 문자열을 돌려주면 출력, { code, out, err }도 돼요. 예외는 code 1과 메시지가 돼요. 'notes-count': async (argv, { plugin }) => { const { out } = await plugin.command(['list-sessions', '-F', '#{session_name}']) return `${out.split('\n').filter(Boolean).length}\n` }, }, }) ``` [`examples/plugins/notify.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/plugins/notify.mjs)는 세션 생성·종료 기록(`zeropty notify-log`)과 조용한 세션 찾기(`zeropty notify-idle 10`)를 더하고, `--desktop`이면 세션이 끝날 때 macOS 알림을 띄워요. ## 이벤트 | 이름 (`-e`) | Rust | 필드 | 언제 | |---|---|---|---| | `session-created` | `Event::SessionCreated` | `session` | `new-session`, 다른 서버에서 넘겨받았을 때 | | `session-exited` | `Event::SessionExited` | `session`, `reason`: `exited` · `killed` · `handed-over` | 프로그램이 끝났을 때(`kill-server`도 `exited`), `kill-session`, 다른 서버로 넘겼을 때 | | `session-renamed` | `Event::SessionRenamed` | `session`(새 이름), `from` | `rename-session` | | `output` | `Event::Output` | `session`, `data`(소켓·C: base64) | 프로그램이 출력한 바이트를 읽을 때마다. 화면과 같이 모아 읽어서 조용한 세션은 \~300ms 단위예요. 이벤트 하나는 64KB까지라서 한꺼번에 많이 읽으면 여러 이벤트로 와요. UTF-8 문자가 두 이벤트에 나뉠 수 있어요 | | `activity` | `Event::Activity` | `session` | 출력을 읽어 화면이 바뀔 때마다. 바이트를 복사하지 않아서 `output`보다 싸요 | | `sessions` | `Events::SESSIONS` | | 위 세 생애 이벤트 | | `all` | `Events::ALL` | | 전부 | `output`을 원하는 플러그인이 있을 때만 엔진이 읽은 바이트를 복사해요. 화면 변화만 알면 되면 `activity`를 쓰고, 필요할 때 `capture-pane`으로 읽어요. - **`output`은 손실이 있을 수 있어요.** 메모리 예산(엔진이 넘김 한 번에 16MB, 플러그인 큐마다 8MB) 때문에, 제때 읽는 플러그인도 한꺼번에 아주 큰 출력(수십 MB 이상)이 오면 일부를 잃어요. 출력 전체가 빠짐없이 필요하면 플러그인 대신 `pipe-pane`(세션 출력을 파일이나 명령으로 그대로 보내요)을 써요. - **`dropped=`는 하나의 합계예요.** 큐가 차서(1024개나 출력 8MB) 버린 이벤트, 엔진이 16MB 예산을 넘어 버린 출력, 엔진 쪽 큐가 넘쳐 버린 이벤트를 모두 같은 숫자에 더해요. 이유별로 나눠 세지는 않아요. ## 명령 - `list-plugins`: 플러그인마다 한 줄. `이름: 상태 (api N) events=... commands=... access=... dropped=N`. 소켓, CLI, 임베디드 호출 모두 돼요. 상태는 셋이에요. - `running`: 이벤트와 명령을 받고 있어요. - `stopped`: 빠지는 중이에요. 연결이 닫혔거나 `remove`됐는데 호스트 루프가 아직 목록에서 지우지 않은 잠깐 동안 보여요. 명령은 이미 가지 않아요. - `failed (이유)`: 패닉(`panicked in 훅: 메시지`), 마감 시간을 넘긴 훅(`stuck in a hook for more than N ms`), 연결 오류(`connection: ...`). 목록에 남아서 이유를 볼 수 있고, 같은 이름으로 다시 붙으면 자리를 내줘요. 실패한 항목은 최근 16개까지만 남기고 오래된 것부터 지워요(이름을 바꿔 가며 다시 붙는 프로그램이 등록부를 끝없이 늘리지 않도록). - `plugin-attach`: 위 1번. 소켓 연결에서만 의미가 있어요(CLI로 보내면 답을 받고 바로 끊겨서 플러그인도 바로 빠져요). - `zeropty plugin-run 라이브러리.dylib`: 위 2번. 서버가 끝나면 끝나요. ## API 레퍼런스 (Rust, `zeropty::plugin`) 안정성 경계는 코드의 모듈·파일 경계와 같아요. | 파일 | 경로 | 누가 써요 | 안정성 | |---|---|---|---| | `src/plugin/api.rs` | `zeropty::plugin::api` (= `zeropty::plugin::*`) | 플러그인 작성자 | 안정 API (아래 버전 정책) | | `src/plugin/registry.rs`, `src/plugin/hosting.rs` | `Plugins`, `Call` | 엔진을 넣는 호스트 | 공개 메서드는 안정 API | | `src/host.rs` | `HostLoop::set_plugins`, `impl Service for Plugins` | 자기 루프를 가진 호스트 | 호스트 루프 API(`zeropty::host`)와 같아요 | | `src/plugin/__private/{remote,native,launch}.rs`, `src/plugin/wire.rs` | `zeropty::plugin::__private` (`#[doc(hidden)]`, 문서에 나오지 않아요) | zeropty 바이너리·C 바인딩·테스트 | **불안정**, semver 밖 | | `src/engine/tap.rs` | (`Event`, `ExitReason`만 `zeropty::plugin`으로 공개) | 엔진 | 큐와 메서드는 크레이트 안에서만 | **안정 API.** 플러그인 작성자와 엔진을 넣는 호스트가 쓰는 부분이에요. 같은 `API_VERSION` 안에서는 더하기만 해요(아래 버전 정책). | 항목 | 설명 | |---|---| | `API_VERSION` | 이 인터페이스의 버전(지금 1). `Manifest::api`와 `plugin-attach -v`를 이것과 비교해요 | | `trait Plugin: Send + 'static` | `manifest()`(필수), `start`, `event`, `command`, `stop`(기본 구현 있음). 훅 넷(`start`, `event`, `command`, `stop`)은 플러그인 스레드에서 한 번에 하나씩 불려요. `manifest()`는 등록할 때 한 번, 플러그인의 새 스레드에서 맨 먼저 불리고 `add`는 그 답을 매니페스트 마감(등록부 마감, 최대 1초)까지만 기다려요. 넘으면 `PluginError::ManifestTimeout(ms)`로 등록을 거절하고, 플러그인은 `manifest()`가 끝나는 대로 자기 스레드에서 버려져요. 그래서 멈춘 `manifest()`도 `add`를 부른 쪽을 최대 1초만 붙잡고 엔진은 붙잡지 않아요. 상수로 바로 만들고 느린 준비는 마감 시간 안에서 도는 `start`에서 해요. 패닉은 잡혀서 `PluginError::ManifestPanicked`가 돼요 | | `Manifest::new(name).events(Events).command(name).access(Access).api(v)` | 이름, 받을 이벤트, 더할 명령, 권한, 버전. `events`·`access`는 이미 있는 것에 더해요. 필드 `events: Events`, `access: Access`(늘 `READ` 포함)는 읽을 수 있어요. `#[non_exhaustive]` | | `Manifest::from_json(text)`, `attach_argv()` | C·라이브러리의 JSON 매니페스트 읽기, 같은 매니페스트의 `plugin-attach` 명령 | | `Event` | `#[non_exhaustive]` enum. `kind() -> Events`(그 이벤트 하나의 비트), `name()`, `session()`, `to_json()` | | `ExitReason` | `Exited`, `Killed`, `HandedOver`. `as_str()`. `#[non_exhaustive]`라 `match`에는 `_` 갈래가 필요해요 | | `PluginError` | 등록·매니페스트 오류. `#[non_exhaustive]` enum: `BadName`, `BadCommand`, `Api { api, supported }`, `UnknownEvent`, `UnknownAccess`, `BuiltinCommand`, `DuplicateCommand`, `NameTaken`, `CommandTaken { owner }`, `BadManifest`, `ManifestPanicked`, `ManifestTimeout(ms)`, `Thread`, `Unsupported`(플러그인을 돌리지 않는 프런트엔드). `Display`는 사람이 읽는 이유, `String::from`은 답의 `err`처럼 줄바꿈을 붙여요. `std::error::Error` 구현 | | `Events` | 받을 이벤트의 집합(타입이 있는 비트 플래그). 상수 `Events::{SESSION_CREATED, SESSION_EXITED, SESSION_RENAMED, OUTPUT, ACTIVITY, SESSIONS, ALL}`. `Copy`, `Eq`, `Hash`, `Debug`(`Events(session-created \| output)`), `Default`(빈 집합), `Display`(소켓과 같은 쉼표 목록). 연산 `\|`, `\|=`, `&`, `&=`, `-`(차집합), `!`(아는 비트 안에서의 여집합). 메서드 `empty()`, `all()`, `is_empty()`, `contains(other)`, `intersects(other)`, `union`, `difference`, `bits() -> u32`, `from_bits(u32) -> Option<Events>`(모르는 비트가 있으면 `None`), `from_bits_truncate`, `parse("a,b") -> Result<Events, PluginError>`(`FromStr`도 같아요), `names() -> String` | | `Access` | 권한의 집합. 상수 `Access::{READ, INPUT, MANAGE, CALL, ALL}`. 트레이트와 메서드는 `Events`와 같아요(`parse("read,input")`, `names()`) | | `Host::command(argv, cwd) -> Reply` | 엔진 명령을 권한 안에서 실행하고 답을 기다려요(마감 시간의 절반까지, 기다리는 시간은 훅 시간에 안 쳐요). 다른 플러그인의 명령은 `CALL`이 있어야 하고, 고리를 만드는 호출은 거절돼요 | | `Host::deadline() -> Duration` | 마감 시간(`set_timeout`, `ZEROPTY_PLUGIN_TIMEOUT_MS`). 훅이 자기 시간으로 이보다 오래 돌면 잘려요 | | `reply_wait(deadline) -> Duration` | 훅이 zeropty 밖을 기다려도 되는 시간: 마감에서 8분의 1(최소 20ms, 최대 절반)을 뺀 값. 감시보다 분명히 먼저 포기하게 해요 | | `Host::name()`, `Host::detach()`, `Host::fail(why)` | 이름, 스스로 빠지기, 실패로 표시하고 빠지기(그 순간부터 명령이 오지 않아요) | | `Reply`, `ok(out)`, `fail(err)`, `Reply::new(code, out, err)` | 명령의 답(`code`, `out`, `err` 필드를 읽어요). 엔진 모듈 것을 다시 내보내요. CLI와 소켓의 답 그대로라 세 필드는 고정이에요(`Reply { code, out, err }`로 직접 만들어도 돼요) | | `is_reserved(name) -> bool` | 엔진이나 호스트(서버, CLI)가 직접 답하는 이름이라 플러그인이 가질 수 없는지. 엔진 쪽은 엔진의 명령 표(`COMMANDS`, 별칭 포함)를 그대로 읽어서 엔진에 명령이 생기면 함께 예약돼요. 그 목록 자체는 API가 아니에요: zeropty가 명령을 더하면 늘어나니 `myplugin-...`처럼 자기 접두사를 쓰세요 | | `Plugins` | 등록부. `add`·`add_boxed`·`check`(`Result<(), PluginError>`), `remove`(없으면 `false`), `names`, `list`, `set_timeout`, `timeout`, `set_shutdown_wait`, `shutdown`, `attach(&engine)`(플러그인이 그 엔진의 루프를 깨우게 해요. 엔진을 받아서 큐 타입(지금은 kqueue)이 API에 드러나지 않아요), `watchdog_ms()`(플러그인이 있을 때 루프가 잠들 수 있는 최대 시간). `host::Service`를 구현해요. 공개 메서드는 모두 안정 API예요 | | `HostLoop::set_plugins(plugins)`, `HostLoop::plugins()` | 호스트 루프가 등록부를 서비스로 돌리게 해요(`zeropty serve`, `Embedded`, `Polled`가 이걸 써요) | | `Embedded::add_plugin`·`add_plugin_boxed`, `Embedded::plugins()`, `Polled::add_plugin`·`add_plugin_boxed`, `Polled::plugins()` | 임베디드 엔진에 붙이기(`Result<(), PluginError>`). 바인딩이 쓰는 `embed::Frontend`에도 `add_plugin_boxed`, `command_start`가 있어요. 둘 다 기본 구현이 있어서 플러그인 이전에 `Frontend`를 구현한 코드도 그대로 컴파일돼요(`add_plugin_boxed` 기본값은 `Err(PluginError::Unsupported)`, `command_start` 기본값은 `command`를 바로 답해요) | | `Embedded::command_start`, `Polled::command_start` `-> Call` | 기다리지 않고 명령 부르기. 엔진 명령은 답이 든 `Call`로 와요 | | `Call` | 진행 중인 명령. `poll() -> Option<Reply>`(답을 한 번 줘요), `wait() -> Reply`, `due_ms()`, `answered()`, `Call::ready(reply)`. 답 전에 drop하면 아직 시작 안 한 명령은 실행되지 않아요. `Polled::timeout_ms()`는 아직 답을 기다리는 `Call`의 마감만 세요. 답을 받았거나 drop한 `Call`은 루프를 깨우지 않아요 | **`zeropty::plugin::__private` (안정 API 아님).** zeropty 자신의 바이너리(`plugin-run`, 플러그인 디렉터리)·C 바인딩·테스트가 쓰는 부분을 이 모듈 하나에 모았어요. **불안정하고 semver 밖**이에요: 버전을 올리지 않고도 언제든 바뀌거나 없어질 수 있어요. 안정 플러그인 API는 `api`(= `zeropty::plugin::*`), `Plugins`, `Call`뿐이고, 플러그인과 호스트는 `__private`를 쓰지 않아요. serde의 `__private`와 같은 관례예요: `#[doc(hidden)]`이라 API 문서에 나오지 않고, 안정 API의 어떤 항목도 이 모듈의 타입을 받거나 돌려주지 않아요. 이름에 `__`가 붙은 경로를 직접 쓰는 것이 곧 불안정한 것을 쓰겠다는 표시예요. cargo 기능으로 숨기지 않은 이유: 바이너리(`src/main.rs`)와 바인딩은 라이브러리와 다른 크레이트라서 공개된 경로가 필요하고, cargo에는 패키지가 자기 바이너리에만 기능을 켜는 방법이 없어요. 기능 뒤에 두면 바이너리를 다른 패키지로 옮기거나(`cargo install --path .`가 깨져요) `required-features`를 걸어야 해서(`cargo install`이 바이너리를 건너뛰어요), 설치 방법을 그대로 두는 쪽을 골랐어요. 그래서 바이너리와 그 테스트는 예전처럼 루트 패키지 `zeropty`에 있어요: ```sh cargo install --path . # zeropty 바이너리 설치 cargo build --release -p zeropty # target/release/zeropty ``` | 항목 | 설명 | |---|---| | `remote` (`Remote`, `parse_attach`) | 소켓 플러그인(`plugin-attach`)의 서버 쪽 | | `launch::{Autostart, configured_dir, programs}` | 플러그인 디렉터리(`ZEROPTY_PLUGIN_DIR`) | | `unsafe native::{load, run}`, `unsafe native::Library::from_parts`, `native::{Api, Def, ENTRY}` | 플러그인 라이브러리 불러오기(`zeropty_plugin_init`), `plugin-run`. 라이브러리를 불러오면 그 코드가 실행되므로 `unsafe`예요(`# Safety`에 조건). 다른 언어에서는 C ABI(`zeropty_load_plugin`)나 `zeropty plugin-run`을 써요 | | `call_json`, `reply_json`, `reply_from_json`, `base64` | 프로토콜 구현용 | | `set_engine_events`, `take_engine_events`, `has_engine_events` | 등록부 없이 엔진 쪽 이벤트 큐를 보는 테스트용 함수 | 크레이트 밖에서는 보이지 않는 것: 엔진 쪽 큐(`Engine::set_tap`, `take_tap`, `has_tap_events`), 등록부의 `pump`·`call`·`owns`, 깨우기 이벤트 태그(`PLUGIN_TAG`)는 `pub(crate)`예요. C ABI (`bindings/c/include/zeropty.h`): `zeropty_add_plugin`, `zeropty_host_command`, `zeropty_load_plugin`, 라이브러리 진입점 `zeropty_plugin_init`과 `zeropty_plugin_api`·`zeropty_plugin_def`, 기다리지 않는 호출 `zeropty_command_start`·`zeropty_call_poll`·`zeropty_call_due_ms`·`zeropty_call_free`. Node(`zeropty.node`): `Engine.commandStart(argv, cwd)`는 늘 `Promise<Reply>`를 돌려줘요. 엔진 명령이면 이미 풀린 Promise예요. `Reply`와 Promise가 섞이지 않아서 늘 `await`하면 돼요. 등록 규칙: 이름과 명령 이름은 영문자·숫자·`- _ . :`(64자까지, `-`로 시작 못 해요), 내장 명령(`is_reserved`가 참인 이름)과 실행 중인 다른 플러그인의 이름·명령은 쓸 수 없어요. 실패하거나 멈춘 플러그인의 이름은 다시 쓸 수 있어요(재연결). 동작 한계값: 플러그인 큐 1024개·출력 8MB(256개와 2MB 아래로 비면 출력 복사 재개), `output` 이벤트 하나 64KB, 엔진 쪽 큐 이벤트 16384개·출력 16MB(한 번 읽은 출력도 16MB까지 복사), 마감 시간 10초(`Host::command`는 5초, 소켓 플러그인의 `call` 답과 소켓 쓰기는 8.75초), 감시 주기 마감 시간의 4분의 1(10ms\~1초), 종료 대기 1초, 남기는 실패 항목 16개. ## 버전 정책 - 플러그인은 자기가 쓰인 버전을 밝혀요(`Manifest::api`, `-v`, 매니페스트의 `api`). zeropty보다 새 버전이면 등록을 거절해요. 옛 버전은 계속 받아요. - 같은 버전 안에서는 더하기만 해요: 새 훅은 기본 구현을 달고 오고, `Event`, `ExitReason`, `PluginError`, `Manifest`는 `#[non_exhaustive]`라 새 이벤트·종료 이유·오류 종류·필드가 생겨도 기존 플러그인이 컴파일돼요(그래서 이 enum들을 `match`할 때는 `_` 갈래를 둬요). `Reply`는 CLI·소켓의 답이자 임베더가 구조체 리터럴로 만드는 타입이라 `code`·`out`·`err` 세 필드로 고정이에요. 더 알려야 할 게 생기면 `Reply`에 필드를 더하지 않고 옆에 새 타입으로 더해요. 소켓 플러그인은 모르는 키와 모르는 `event`를 무시하면 돼요. C의 `zeropty_plugin_api`는 끝에만 필드를 더하고 `size`로 알려요. - 바이트 단위 호환이 필요한 것(메시지의 키 순서 `event`·`call`·`reply`·`id`가 맨 앞)은 테스트로 고정해 뒀어요. - **`zeropty::plugin::__private`는 이 정책 밖이에요.** zeropty 자신의 바이너리·바인딩·테스트용이라 semver를 따르지 않고, 버전을 올리지 않고도 바뀌거나 없어질 수 있어요. 쓰면 다음 릴리스에서 깨질 수 있어요. 컴파일러가 막지는 않아서(serde의 `__private`와 같은 관례), 이 이름을 쓰는 코드는 스스로 그 위험을 지는 거예요. - 이벤트와 권한은 날 `u32` 비트가 아니라 `Events`·`Access` 타입이에요. 이 타입은 이 버전이 아는 비트만 담아서(`from_bits`는 모르는 비트에 `None`을 돌려줘요) 모르는 비트를 가진 매니페스트를 만들 수 없어요. 그래서 그런 매니페스트를 거절하던 `PluginError::UnknownEventBits`·`UnknownAccessBits`는 첫 릴리스 전에 없앴어요(`#[non_exhaustive]` enum이라 이 변경으로 깨지는 `match`는 없어요). 소켓·C·JSON의 형식(이름, 쉼표 목록)은 그대로예요. - C·dylib의 JSON 매니페스트(`Manifest::from_json`)는 엄격해요. 다섯 키(`name`, `events`, `commands`, `access`, `api`) 밖의 키, 문자열이 아닌 목록 값, 타입이 틀린 값은 등록 오류(`bad manifest: ...`)예요. `"event"`처럼 철자를 틀려서 이벤트 0개로 조용히 등록되는 일이 없게 하려는 거예요. ## 동작 모델 [작성자가 알아야 할 것](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#작성자가-알아야-할-것)의 규칙을 자세히 적었어요. 각 규칙을 확인하는 테스트는 [테스트](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#테스트)에 있어요. 등록부·서버·엔진이 어떻게 이어져 있는지는 [플러그인 내부](/ko/guide/plugins-internals)에 있어요. **스레드** - 플러그인 코드는 엔진 스레드에서 돌지 않아요. 플러그인마다 전용 스레드가 하나 있고, 훅(`start`, `event`, `command`, `stop`)은 그 스레드에서 한 번에 하나씩 불려요. 훅은 블로킹해도 돼요. - 플러그인이 엔진에 시키는 명령(`Host::command`, 소켓은 `id` 명령, C는 `zeropty_host_command`)은 엔진 스레드에서 다른 명령과 똑같이 실행돼요. - 엔진 스레드는 플러그인 소켓에 블로킹하지 않아요. `plugin-attach`의 성공 답과 그 연결에 밀린 답은 플러그인 스레드가 보내고, 성공 답이 갈 때는 이미 등록이 끝나 있어요. **이벤트** - 엔진은 어떤 플러그인도 원하지 않는 종류의 이벤트는 만들지 않아요. - 이벤트는 플러그인마다 큐(1024개)로 가요. 호스트 루프가 한 바퀴 돌 때(`pump`) `try_send`로 넣고, 큐가 차 있으면 그 이벤트는 버리고 `list-plugins`의 `dropped=`에 세요. 느린 플러그인은 이벤트를 잃을 뿐 엔진을 붙잡지 못해요. - 큐는 개수만이 아니라 바이트로도 막혀요. `output` 이벤트는 64KB 이하 조각으로 나뉘고, 플러그인 큐마다 출력 8MB까지만 들어가요(넘는 조각은 버리고 `dropped=`에 세요). 엔진도 한 번 읽은 출력을 16MB까지만 복사하고 나머지는 이벤트 하나를 버린 것으로 세요. 그래서 읽지 않는 플러그인이 있어도 서버가 더 쓰는 메모리는 이 예산들(합쳐서 수십 MB)을 넘지 않아요. 예전에는 이벤트 하나가 한 번에 읽은 출력 전부라서, 200MB `yes`와 읽지 않는 `output` 플러그인 하나로 서버 메모리가 141MB에서 656MB가 됐어요. `a_burst_to_a_plugin_that_never_reads_stays_within_the_budgets`가 서버의 최대 메모리(`ri_lifetime_max_phys_footprint`)를 플러그인 없을 때와 비교해 64MB 안으로 고정해요(이 기계에서 101MB 대 131MB). 이 측정은 기계 부하에 흔들릴 수 있어서 `ZEROPTY_REQUIRE_ALL=1`일 때만 돌고, 예산 자체는 결정적인 단위 테스트(`engine::tap`의 `bursts_come_in_bounded_pieces`, 등록부의 바이트 예산 테스트)가 늘 확인해요. - `output`(또는 `activity`)을 원하는 플러그인이 모두 큐가 가득 찼으면, 엔진은 출력을 복사하지도 이벤트를 만들지도 않고 `dropped=`만 세요. 그중 하나의 큐가 4분의 1(256개) 아래로 비면 다시 복사해요. 생애 이벤트(`session-*`)는 이렇게 끄지 않고 큐에서 하나씩 세요. - 엔진 쪽 큐에도 상한(이벤트 16384개, 출력 16MB)이 있어요. 호스트가 `pump`를 드물게 불러서 넘친 이벤트는 그 종류를 원한 플러그인의 `dropped=`에 더해요. **명령과 마감 시간** - 플러그인 명령을 부른 쪽은 마감 시간 안에 답을 못 받으면 `plugin timed out` 오류를 받아요. - 소켓 서버는 플러그인 명령의 답을 비동기로 기다려요. 기다리는 건 그 연결뿐이고 다른 클라이언트는 그대로 처리돼요. 같은 연결의 다음 명령은 순서를 지키려고 그 뒤에 처리돼요. - 엔진을 넣은 호스트는 두 가지로 불러요. `command`는 답이나 마감까지 돌아오지 않아요. `command_start`(C `zeropty_command_start`, Node `commandStart`는 늘 `Promise<Reply>`)는 바로 돌아오고, 답은 나중에 `Call::poll`로 가져가요. 자세한 건 [호스트에서 플러그인 명령 부르기](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#호스트에서-플러그인-명령-부르기)에 있어요. - polled 엔진의 `Polled::command`는 기다리는 동안에도 `process`가 하는 일(세션 출력 읽기, `listen`한 소켓의 클라이언트, 플러그인이 시킨 엔진 명령, 감시)을 계속 해요. 그 사이에는 엔진의 kqueue에서 잠들고, 플러그인의 답·세션 출력·클라이언트·엔진 타이머가 깨워요. CPU를 돌리며 기다리지 않아요. - 소켓 플러그인과 `plugin-run` 라이브러리의 `call` 답은 `reply_wait(마감)`(마감의 8분의 7, 기본 8.75초)까지 기다려요. 넘으면 그 호출만 `plugin timed out (no reply within N ms; a later reply is ignored)`로 실패하고, 뒤늦게 온 `reply`는 버려요. 플러그인은 `running`으로 남아요. 플러그인에 한 줄(이벤트, 호출, 답)을 쓰는 데도 같은 시간을 통틀어 한 번만 줘요. 소켓 버퍼가 차서 그 안에 다 못 쓰면 그 플러그인은 `failed (connection: not read for N ms)`가 돼요. 마감보다 먼저 끝나므로 감시가 `stuck in a hook`으로 자르지 않아요. (`write`마다 시간 제한을 걸면 macOS는 몇 바이트라도 보낸 `write`를 성공으로 돌려줘서 `write_all`이 제한을 여러 번 기다렸고, 감시가 먼저 잘랐어요.) - `Host::command`는 마감 시간의 절반까지 기다려요. 넘으면 `명령: no answer within N ms (half the plugin deadline)`를 받아요. 남은 절반은 이 플러그인을 부른 쪽에 이유를 담아 답하는 데 써요. - 부른 쪽이 포기한 명령은 나중에 실행되지 않아요. 아직 시작하지 않은 명령은 버리고 오류 끝에 `not run`(플러그인 큐에서 기다리기만 했으면 `busy; not run`)을 붙여요. 이미 시작한 명령이면 `started, may still take effect`(플러그인 명령은 `plugin timed out`)를 붙여요. 시작과 포기는 원자 변수 하나로 한 번만 정해지므로, 실패로 알린 명령이 뒤에 실행되는 일은 없어요. 답을 받기 전에 버린 `Call`(C `zeropty_call_free`)도 같아요. - 고리를 만드는 호출은 바로 거절해요. A가 B의 명령을 기다리는 동안 B가 A의 명령을 부르면(`call` 권한) `명령: call cycle (b -> a -> b): plugin a is waiting for b`를 받아요. 더 긴 고리도 같아요. 두 플러그인은 `running`으로 남아요. **실패 격리** - 훅에서 패닉이 나면 그 플러그인만 `failed (panicked in 훅: 메시지)`가 되고, 부른 쪽은 오류를 받아요. 실패한 순간부터 명령이 그 플러그인으로 가지 않고, 같은 이름으로 다시 등록할 수 있어요. - 훅 하나가 자기 시간으로 마감 시간보다 오래 돌면 `failed (stuck in a hook for more than N ms)`가 되고 더는 이벤트와 명령을 받지 않아요. Rust 스레드는 밖에서 끝낼 수 없어서, 그 스레드는 훅에서 돌아올 때 끝나요. - 영영 돌아오지 않는 훅은 그 스레드를 끝까지 붙잡아요. 이건 피할 수 없어요(Rust에는 스레드를 밖에서 죽이는 방법이 없어요). 그래도 엔진은 영향을 받지 않아요. 감시가 그 플러그인을 `failed`로 표시하고 큐를 닫으니 이벤트도 명령도 더 가지 않고, 엔진은 원래 플러그인 큐나 스레드를 기다리는 일이 없어요(`try_send`만 해요). 그 플러그인 명령을 부른 쪽은 마감 시간에 오류를 받고, `shutdown`은 기다림(기본 1초)이 끝나면 그 스레드를 떼어 놓고 돌아와요. 남는 비용은 멈춘 스레드 하나와 그 플러그인이 쥔 메모리뿐이고, 프로세스가 끝날 때 사라져요. 플러그인을 다시 시작해야 하면 같은 이름으로 새로 등록할 수 있어요. 이게 걱정되는 플러그인은 프로세스 밖(소켓 플러그인, `plugin-run`)으로 돌리면 멈춘 프로세스를 OS가 끝낼 수 있어요. - `manifest()`도 플러그인 스레드에서 돌아요. `add`는 플러그인 스레드를 먼저 띄우고 거기서 `manifest()`를 불러서, 그 답을 매니페스트 마감(등록부 마감과 1초 중 짧은 쪽)까지만 기다려요. 넘으면 `ManifestTimeout`으로 거절하고 이름도 비워 둬요. 그 스레드는 `manifest()`가 돌아오면 플러그인을 버리고 끝나요(영영 안 돌아오면 위의 멈춘 훅처럼 스레드 하나가 남아요). 등록부 잠금은 `manifest()`의 답을 받은 뒤에만 잡으니, 기다리는 동안 다른 플러그인 등록·명령·엔진은 막히지 않아요. - 자기 시간만 세요. 훅 안에서 `Host::command`로 엔진이나 다른 플러그인을 기다리는 시간은 빼요. 느린 쪽은 자기 훅 시간으로 판정돼요. - 감시는 호스트 루프의 `pump`에서 돌아요. 플러그인이 하나라도 있으면 호스트 루프는 마감 시간의 4분의 1(10ms\~1초)보다 오래 잠들지 않아요(`Plugins::watchdog_ms`를 `HostLoop::timeout_ms`가 셈에 넣어요. polled 엔진은 `timeout_ms()`, 서버와 `Embedded`는 루프가 잠들 시간에 들어 있어요). 그래서 할 일이 없는 엔진에서도 멈춘 훅은 마감 시간의 1.25배 안에 잘려요. 플러그인이 없으면 이 상한도 없어요. - 프로세스 밖 플러그인이 죽으면 서버는 연결이 끊긴 것만 봐요. 그 플러그인은 등록부에서 빠져요. **종료** - `Plugins::shutdown`(그리고 `Embedded`·`Polled`의 drop)은 플러그인마다 `stop`을 보내고 스레드가 끝나기를 `set_shutdown_wait`(기본 1초, 0이면 기다리지 않음)까지 기다려요. 그때도 훅 안에 멈춰 있는 스레드는 떼어 놓고(detach) 돌아와요. 그 스레드는 훅에서 돌아오면 `stop` 없이 끝나요. - 프로세스 밖 플러그인은 서버가 끝나면 EOF를 받아요. --- <!-- https://zeropty.pubdoc.dev/ko/reference/rust-api --> # Rust API Rust 크레이트예요. 내 프로세스 안의 엔진, 서버 플러그인으로 쓰는 법, Rust 로 쓴 플러그인이 있어요. 생성된 rustdoc 은 아래처럼 cargo doc 으로 봐요. ## Rust API 문서 `zeropty`, `zeropty_c`, `zeropty_node` 세 crate의 rustdoc이에요. crates.io에는 올리지 않아요(`./build.sh`로 빌드해요). 웹에 올린 주소는 없고, 저장소에서 만들어 열어요. ```sh cargo doc --no-deps --workspace --open ``` crate 첫 페이지에 구조와 실행되는 예제가 있고, 공개 함수·타입마다 예제가 붙어 있어요(옛 경로로 남겨 둔 `#[deprecated]` 항목은 빼고). Rust 예제 200여 개는 doctest라서 `cargo test --release --doc`이 모두 실제로 돌려 봐요. 공개 항목은 모두 문서가 있고(세 crate 모두 `#![warn(missing_docs)]`), `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace`가 경고 없이 끝나요. `scripts/check.sh`가 둘 다 확인해요(rustdoc은 `--quick`이 아닐 때). 어디서부터 읽을지: | 하고 싶은 일 | rustdoc에서 볼 곳 | |---|---| | 내 프로세스에 엔진 넣기, 여러 스레드에서 명령 | `embed::Embedded`, `Embedded::command`, `Embedded::with` | | 스레드 없이 호스트 이벤트 루프로 돌리기 | `embed::Polled`, `Polled::fd`, `Polled::timeout_ms`, `Polled::process` | | 글자가 나올 때까지 폴링 없이 기다리기 | `engine::Engine::wait_start`, `wait_check`, `wait_due_ms`, `wait_end` | | CLI와 소켓 클라이언트가 내 세션에 닿게 하기 | `Embedded::listen`, `Polled::listen`, `server` 모듈 | | 실행 중인 세션을 다른 프로세스로 옮기기 | `engine::handover::pull`, `push`, `Engine::export_session` | | 엔진만으로 루프 짜기, 내 fd도 같은 큐에서 기다리기 | `engine::Engine`, `Engine::try_poll`, `Engine::watch`, `host::HostLoop` | | 내 터미널을 세션에 붙이기 | `Engine::attach`, `viewer_events`, `zeropty::tty` | | 소켓 클라이언트를 직접 짜기 | `protocol` 모듈(`Request`, `reply_line`, `ClientFrame`, `ServerFrame`) | | 엔진을 가짜 OS로 테스트하기 | `engine::Backend`, `Poller`, `Clock`, `Programs`, `Engine::with_backend` | | 플러그인 만들기, 호스트에 붙이기 | `plugin` 모듈(`plugin::api`), `Embedded::add_plugin`, `Polled::command_start` | | 터미널 에뮬레이터만 쓰기 | `term::Term`, `Term::freeze`, `Term::export` | | C, Node에서 쓰기 | `zeropty_c`, `zeropty_node` crate 첫 페이지 | ## 내 프로세스 안에: 임베디드 서버 없이 엔진을 프로세스 안에 넣어요. 명령 형식과 결과는 소켓과 같아요. `wait-pane`만 예외로 함수 호출로는 안 돼요([위](/ko/reference/commands#기다리기와-찾기) 참고). 세션은 이 프로세스가 살아 있는 동안만 살아요(프로세스가 끝나면 프로그램들은 EOF를 받아요). 남기고 싶으면 끝나기 전에 서버로 넘겨요(아래 `push-sessions`). **Node:** import만 바꾸면 돼요. 예제: [`examples/node/embedded.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/embedded.mjs) ```js import { zeropty, open } from './client/embedded.mjs' // './client/zeropty.mjs' 대신 await zeropty(['new-session', '-d', '-s', 'w', 'exec cat']) ``` | 함수 | 하는 일 | |---|---| | `zeropty(argv, { cwd })`, `zeroptySync(argv, { cwd })` | 서버 클라이언트와 같은 호출, 같은 결과. `send-keys` p50 0.06\~0.07ms, `capture-pane` 0.06\~0.07ms(바쁜 세션 0.10\~0.17ms) ([측정](/ko/guide/benchmarks)) | | `open({ history, sizeDir, polled })` | 첫 명령 전에 옵션을 주고 엔진을 시작해요(안 부르면 첫 명령이 기본값으로 시작해요) | | `listen(소켓)` | [서버 플러그인](/ko/reference/rust-api#서버-플러그인-내-프로그램이-소켓도-열기) | **C (또는 FFI가 되는 어떤 언어든):** 헤더는 [`bindings/c/include/zeropty.h`](https://github.com/CreatiCoding/zeropty/blob/main/bindings/c/include/zeropty.h), 예제는 [`examples/c/hello.c`](https://github.com/CreatiCoding/zeropty/blob/main/examples/c/hello.c)예요. ```c #include "zeropty.h" // cc app.c -Idist -Ldist -lzeropty_c zeropty *a = zeropty_open(NULL); // 또는 {"history": 2000, "size_dir": "/tmp"} char *r = zeropty_command(a, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"x\",\"exec cat\"],\"cwd\":\"/\"}"); zeropty_free(r); // r은 {"code":0,"out":"","err":""} zeropty_close(a); ``` | 함수 | 하는 일 | |---|---| | `zeropty_open(options_json)` | 엔진 스레드가 있는 엔진. 옵션은 `NULL`이나 `{"history": 줄 수, "size_dir": "폴더"}` | | `zeropty_command(a, request_json)` | 명령 하나. 답은 JSON 문자열이고 `zeropty_free`로 풀어요 | | `zeropty_listen(a, path)` | 소켓도 열기. 0, 실패하면 -1 | | `zeropty_close(a)` | 엔진을 멈춰요. 세션은 프로그램이 끝날 때까지 돌아요(먼저 `kill-server`를 보내면 끝나요) | | `zeropty_open_polled`, `zeropty_fd`, `zeropty_timeout_ms`, `zeropty_process` | 엔진 스레드 없이 호스트 루프로 돌리기(아래) | `zeropty_open`의 명령은 어느 스레드에서 불러도 돼요. 화면 읽기, 키 보내기, 조회는 부른 스레드에서 바로 처리하고, 나머지는 엔진 스레드에 넘겨요. **Rust:** crates.io에는 아직 없어서 git 의존성으로 넣어요. 같은 커밋에 머물고 싶으면 `rev = "<커밋>"`을 더해요. ```toml # Cargo.toml [dependencies] zeropty = { git = "https://github.com/CreatiCoding/zeropty" } ``` 그리고 `zeropty::embed::Embedded`(엔진 스레드)나 `zeropty::embed::Polled`(호스트 루프)를 써요. API 문서는 이 저장소에서 `cargo doc --no-deps --workspace --open`, 예제는 `cargo run --release --example embed`, `cargo run --release --example polled`예요. ```rust use zeropty::{embed::Embedded, engine::Options}; fn main() { let zp = Embedded::start(Options::default()); let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>(); let r = zp.command(&argv(&["new-session", "-d", "-s", "w", "exec cat"]), "/"); assert_eq!(r.code, 0, "{}", r.err); zp.command(&argv(&["send-keys", "-t", "=w:", "-l", "hello"]), "/"); print!("{}", zp.command(&argv(&["capture-pane", "-p", "-t", "=w:"]), "/").out); // hello zp.command(&argv(&["kill-server"]), "/"); } ``` 빌드에는 macOS와 Xcode Command Line Tools가 필요해요(`zstd-sys`가 C 코드를 컴파일해요). 터미널이 필요한 프로그램을 띄우는 shim은 crate에 들어 있지 않아요. 그럴 때는 이 저장소의 `./build.sh`로 만든 `dist/zeropty-tty`를 써요. **polled: 엔진 스레드 없이 (Node 기본값, 대기 400개에서 0.74MB → 0.45\~0.46MB).** 호스트의 이벤트 루프가 엔진을 돌려요. 모든 호출은 그 루프 스레드에서 해요. Node에서는 libuv가 엔진 fd를 지켜봐서 따로 할 일이 없어요. ```js open({ polled: false }) // Node에서 엔진 스레드를 쓰려면 (worker 스레드에서 부를 때). 첫 명령 전에 불러요 ``` ```c zeropty *a = zeropty_open_polled(NULL); for (;;) { // 호스트 루프: 엔진 fd가 읽을 수 있거나 타임아웃이 지나면 zeropty_process struct pollfd p = { zeropty_fd(a), POLLIN, 0 }; poll(&p, 1, (int)zeropty_timeout_ms(a)); zeropty_process(a); } ``` 플러그인이 더한 명령은 답을 기다리는 동안 부른 스레드를 잡을 수 있어요(마감 기본 10초). 호스트 루프를 잡지 않으려면 `zeropty_command_start`로 시작하고 `zeropty_process` 뒤에 `zeropty_call_poll`로 답을 가져가요(Rust는 `Polled::command_start`, Node는 `await zeropty(...)`). 엔진 명령은 이 방식으로 불러도 바로 답이 들어 있어요. 자세한 건 [PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#호스트에서-플러그인-명령-부르기)에 있어요. ### 서버 플러그인: 내 프로그램이 소켓도 열기 `listen(소켓)`을 부르면 `zeropty serve`와 같은 서버가 내 프로그램 안에서 돌아요. 그러면 `zeropty` CLI, `zeropty attach`, zsh 함수, bash builtin, `client/zeropty.mjs`가 별도 서버 프로세스 없이 이 프로그램의 세션을 다뤄요. 연결은 엔진과 같은 루프에서 처리해요(polled면 호스트 루프, 엔진 스레드 방식이면 엔진 스레드). ```js import { zeropty, listen } from './client/embedded.mjs' listen(`${process.env.HOME}/.zeropty/myapp.sock`) // 이미 다른 서버가 답하는 소켓이면 예외 ``` ```sh ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' # 다른 터미널에서 ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w ``` ```c if (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* 못 열었어요 */ } ``` - 소켓 경로는 103바이트 이하예요. 소켓은 0600, 새로 만든 폴더는 0700 권한이에요. - 그 경로에서 이미 서버가 답하면 `listen`은 실패해요(Node는 예외, C는 -1). 아무도 답하지 않는 소켓 파일(끝난 서버가 남긴 것)은 넘겨받고, 소켓이 아닌 파일이 있으면 지우지 않고 실패해요. - 소켓에서 온 `kill-server`는 세션만 끝내요. 내 프로그램은 계속 돌고 소켓도 계속 열려 있어요(독립 서버는 프로세스가 끝나요). - 엔진을 닫으면(`zeropty_close`, Rust의 `Embedded`·`Polled` 해제) 소켓 파일도 지워요. 그사이 다른 서버가 같은 경로를 차지했으면 그 소켓은 건드리지 않아요. - 세션은 여전히 내 프로그램과 같이 끝나요. 프로그램보다 오래 두려면 끝나기 전에 독립 서버로 `push-sessions` 해요. - 엔진을 멈추거나 프로세스가 끝나도 소켓 파일은 남아요. 아무도 답하지 않는 소켓이라 다음 `listen`이 넘겨받아요. - `mstats`는 독립 서버에만 있어요. ## Rust 로 쓴 플러그인 플러그인은 `zeropty::plugin`만 써요(`Reply`, `ok`, `fail`도 여기서 다시 내보내요). 엔진 모듈 구조가 바뀌어도 플러그인 코드는 그대로예요. ```rust use zeropty::embed::Embedded; use zeropty::engine::Options; use zeropty::plugin::{ok, Access, Event, Events, Host, Manifest, Plugin, Reply}; struct Bell; impl Plugin for Bell { fn manifest(&self) -> Manifest { Manifest::new("bell").events(Events::SESSION_EXITED).command("bell-test").access(Access::READ) } fn event(&mut self, event: &Event, _host: &Host) { if let Event::SessionExited { session, reason } = event { eprintln!("\x07{session} {}", reason.as_str()); } } fn command(&mut self, _argv: &[String], _cwd: &str, host: &Host) -> Reply { let r = host.command(&["list-sessions"], "/"); // 엔진 명령, 엔진 스레드에서 실행돼요 ok(format!("{} sessions\n", r.out.lines().count())) } } let e = Embedded::start(Options::default()); e.add_plugin(Bell)?; // 이 줄이 돌아온 순간부터 이벤트를 받아요 e.command(&["bell-test".into()], "/"); // 소켓(listen)과 CLI에서도 똑같이 불려요 ``` - `Polled::add_plugin`도 같아요. 플러그인 명령을 기다리는 동안에도 polled 엔진은 돌아가요. 호스트 스레드를 잡지 않으려면 `command_start`를 써요([호스트에서 플러그인 명령 부르기](https://github.com/CreatiCoding/zeropty/blob/main/docs/ko/PLUGINS.md#호스트에서-플러그인-명령-부르기)). - 훅에서 zeropty 밖의 무언가(다른 프로세스, 네트워크)를 기다린다면 `zeropty::plugin::reply_wait(host.deadline())`까지만 기다리세요. 그러면 늦을 때 그 호출만 실패하고 플러그인은 감시에 잘리지 않아요(소켓 플러그인의 서버 쪽이 이렇게 해요). - 실행해 보기: `cargo run --release --example plugin_activity` (세션마다 출력한 바이트 수를 세는 `activity` 명령). 인자로 소켓 경로를 주면 계속 떠 있어서 `ZEROPTY_SOCK=경로 zeropty activity`로 불러 볼 수 있어요. - 자기 루프를 가진 호스트(`Engine`을 직접 돌리는 프로그램)는 `host::HostLoop`에 등록부를 서비스로 붙여요. 세 줄이고, `src/cli/serve.rs`가 이렇게 해요. ```rust let plugins = Plugins::new(); plugins.attach(&engine)?; // 플러그인이 루프를 깨울 수 있게 (엔진 큐에 사용자 이벤트 하나) server.set_plugins(plugins.clone()); // 소켓: list-plugins, 플러그인 명령, plugin-attach let mut host = HostLoop::new(Some(server), Waiting::WithEngine); host.set_plugins(plugins.clone()); // 매 바퀴 서버 다음에 pump, 잠들 시간에 감시 상한 loop { host.turn(&mut engine, Source::Wait(None), Yield::Never, |_, _, _| {})?; } ``` 소켓 없이 쓰는 호스트는 `Plugins::add`로 붙이고, 플러그인 명령은 `Plugins::list`와 `Embedded`·`Polled`의 `command`/`command_start`처럼 부르면 돼요. - 등록이 실패하면 `PluginError`로 이유를 구분해요. 예: `Err(PluginError::CommandTaken { owner, .. })`면 다른 이름으로 다시 시도하고, `Err(PluginError::Api { supported, .. })`면 `.api(supported)`로 낮춰요. 나머지는 `Err(e) => eprintln!("{e}")`처럼 `_` 갈래로 받아요. --- <!-- https://zeropty.pubdoc.dev/ko/reference/socket-protocol --> # 소켓 프로토콜 서버 소켓의 JSON 프로토콜이에요. 서버 사이에 세션을 옮기는 부분도 있어요. ## 다른 프로그램에서: 소켓 서버 소켓(`~/.zeropty/mux.sock`, `ZEROPTY_SOCK`으로 바꿔요)에 JSON 한 줄을 보내면 한 줄로 답해요. 연결 하나로 여러 명령을 보낼 수 있고, 답은 순서대로 와요. 소켓은 0600 권한이라 같은 사용자(와 root)만 접속할 수 있어요. ``` → {"argv":["capture-pane","-p","-t","=work:"],"cwd":"/"} ← {"code":0,"out":"...화면...","err":""} ``` - `cwd`는 상대 경로(`new-session -c`, `load-buffer`)의 기준 폴더예요. - 요청에 `"raw":true`를 넣으면 답이 JSON 대신 `<code> <out 바이트 수> <err 바이트 수>` 한 줄과 그 뒤의 out, err 바이트 그대로 와요. JSON을 풀기 어려운 클라이언트(zsh 함수)용이에요. - `attach` 연결은 첫 답 뒤로 프레임을 주고받아요: `[종류 u8][길이 u32 LE][바이트]`. 서버 → 클라이언트는 0(출력), 1(세션 끝), 클라이언트 → 서버는 0(키), 1(크기: 열·행 u16 LE), 2(떼기)예요. - 첫 요청이 `export-sessions`나 `import-sessions`인 연결은 세션을 넘기는 데 써요(내부용): [소켓 프로토콜](/ko/reference/socket-protocol#소켓-프로토콜-export-sessions-import-sessions). ```sh # 아무 언어에서나: 예를 들어 nc로 printf '{"argv":["list-sessions"],"cwd":"/"}\n' | nc -U ~/.zeropty/mux.sock ``` ## 세션 옮기기 세션을 다른 프로세스로 옮겨요. **실행 중인 프로그램은 그대로**예요(pid도 같아요). 화면·스크롤백은 바이트로, 세션의 fd는 fd 전달(SCM_RIGHTS)로 넘겨요. ```sh zeropty pull-sessions -s ~/.zeropty/other.sock [이름...] # 다른 서버의 세션을 이 서버로 (이름 없으면 전부) zeropty push-sessions -s ~/.zeropty/other.sock [이름...] # 이 서버의 세션을 다른 서버로 zeropty restart-server # 새 서버가 세션을 전부 넘겨받고 소켓 자리를 차지 ``` - 임베디드 엔진에서도 같은 명령이 돼요. 예를 들어 Node 앱이 시작할 때 서버의 세션을 `pull-sessions`로 가져오고, 끝날 때 `push-sessions`로 돌려주면 앱을 재시작해도 세션이 안 끊겨요. - `restart-server`는 지금 실행한 `zeropty` 바이너리로 새 서버를 띄워요. 그래서 **세션을 잃지 않고 업그레이드**하는 방법이기도 해요. 전환하는 몇 ms 사이에 들어온 명령은 옛 서버로 가요. - 받는 쪽이 "받았다"고 답한 세션만 보낸 쪽이 놓아요. 중간에 실패하면 세션은 원래 쪽에 그대로 있어요. 이름이 이미 있는 세션은 옮기지 않아요. - **한계:** 옮기는 순간 이스케이프 시퀀스가 반쯤 들어와 있었다면, 그 나머지는 글자로 찍혀요. ### 소켓 프로토콜: export-sessions, import-sessions `pull-sessions`, `push-sessions`, `restart-server`가 서버 소켓에서 쓰는 **내부용** 요청이에요. 직접 보낼 일은 거의 없고, 세션 레코드 형식은 버전마다 바뀔 수 있어요(첫 바이트가 레코드 버전이라, 다른 버전의 레코드는 `session record of another version`으로 거절해요). 사람이 읽을 레퍼런스로 남겨 둬요. 구현: `src/engine/handover.rs`. - 연결의 **첫 요청 줄**이 `{"argv":["export-sessions", 이름...],"cwd":""}` 또는 `{"argv":["import-sessions"],"cwd":""}`이면, 그 연결은 세션을 넘기는 데만 쓰고 끝나면 닫혀요. 그동안 서버는 다른 일을 하지 않아요(보통 몇 ms). - 양쪽 모두 한 단계에 10초까지 기다려요. - 세션 하나는 `[레코드 길이 u32 LE][레코드]`로 가요. 세션의 fd(입력, 출력 중 열려 있는 것, 0\~2개)는 길이 4바이트에 SCM_RIGHTS로 붙여 보내요. 길이 0이 끝 표시예요. 레코드는 256MB를 넘을 수 없어요. - 나머지 줄은 모두 JSON 한 줄이에요. ``` export-sessions (가져가는 쪽 → 서버) → {"argv":["export-sessions","a","b"],"cwd":""} 이름이 없으면 전부 ← [길이][레코드] ... [0] 세션마다 fd와 함께 ← ["b"] 서버에 없는 이름 → {"taken":["a"],"failed":[]} 받은 세션. 서버는 이것만 놓아요 import-sessions (주는 쪽 → 서버) → {"argv":["import-sessions"],"cwd":""} ← "ready" → [길이][레코드] ... [0] ← {"taken":["a"],"failed":["duplicate session: b\n"]} 주는 쪽은 taken만 놓아요 ``` `failed`에는 받지 못한 이유가 들어가요(같은 이름이 이미 있음, 레코드 버전이 다름 등). 어느 단계에서든 연결이 끊기거나 형식이 틀리면 받은 쪽이 받은 세션을 놓고, 세션은 원래 쪽에 남아요. --- <!-- https://zeropty.pubdoc.dev/ko/reference/tmux-compatibility --> # tmux 호환 tmux 와 비교해 zeropty 가 하는 것과 하지 않는 것이에요. ## tmux와 기능 비교 zeropty는 **프로그램을 띄워 두고 화면을 읽고 키를 보내는 일**에 집중해요. 사람이 터미널을 나눠 쓰는 기능은 대부분 없어요. | 기능 | tmux | zeropty | |---|---|---| | 세션 만들기·끝내기·목록, 키 보내기, 화면 읽기 | ✅ | ✅ 같은 인자 | | `capture-pane` 옵션 | 많음 | `-p -e -J -N -S -E -b -q` (나머지는 오류) | | 버퍼 | `load/paste/set/show/save-buffer` 등 | `load-buffer`, `paste-buffer`, `capture-pane -b` | | `pipe-pane`, `rename-session`, `resize-window`, `set-environment -g` | ✅ | ✅ (`pipe-pane -I`는 없음) | | 포맷 변수 `#{...}` | 수백 개 | 세션·창·패널·커서·히스토리·프로세스 변수 25개 (조건식, 수식어 없음) | | 사람이 붙어서 보기 (`attach`) | ✅ | ✅ 읽기 전용(`-r`), Ctrl-b d, 터미널 크기 맞춤 | | 창(window)·창 분할(pane) | ✅ | ❌ 세션 하나에 프로그램 하나 | | 상태 표시줄, 복사 모드, 단축키 설정, 설정 파일, 훅 | ✅ | ❌ | | 여러 클라이언트가 한 세션에 붙기 | ✅ | ✅ (크기는 마지막에 붙은 터미널을 따라요) | | PTY | 세션마다 1개 (macOS 한도 511\~999개) | 안 씀, 세션 1000개 이상 | | 전체화면 프로그램 (vim 등) | ✅ | `zeropty-tty <프로그램>`으로 띄우면 돼요. vim(편집·저장), less(페이지 넘기기·종료), python REPL(줄 편집·히스토리), claude를 확인했어요. `top`·`ps`처럼 권한(setuid, entitlement)이 필요한 시스템 프로그램은 안 돼요 | | 프로그램용 접속 | 제어 모드(`-C`) | JSON 소켓 프로토콜, Node 클라이언트 | | 모든 세션 화면에서 글자 찾기 | ❌ 세션마다 `capture-pane` | ✅ `grep-panes` (1000개 16\~32ms) | | 키 입력의 라인 디시플린 (Enter, `C-c` 시그널, 줄 편집, 에코, `C-d`) | ✅ PTY | ✅ 엔진이 흉내 내요(같은 키를 tmux와 대조). `C-d`의 EOF는 입력을 닫아요 | | 화면에 글자가 뜨거나 출력이 멈출 때까지 기다리기 | ❌ `capture-pane`을 반복해서 확인 | ✅ `wait-pane` (서버가 출력이 올 때만 확인, 감지 2ms) | | 라이브러리로 내장 | ❌ | ✅ C ABI, Node 모듈, Rust crate | | 실행 중인 세션을 다른 프로세스로 옮기기 | ❌ | ✅ `pull-sessions`, `push-sessions` | | 서버 재시작·업그레이드 후에도 세션 유지 | ❌ | ✅ `restart-server` | | 플랫폼 | 대부분의 Unix | macOS와 Linux (kqueue 또는 epoll, DYLD 또는 LD_PRELOAD shim) | --- <!-- https://zeropty.pubdoc.dev/reference/c-abi --> # C ABI Every function, type and macro in `zeropty.h`, the header of `libzeropty_c.dylib`. For building, linking and a first program, see [Embed zeropty in C or Rust](/guide/embed). The build checks this list: `api/c-abi.txt` is compared with the library and the header, and the site's build fails if a symbol in it is missing here. The source is [`bindings/c/include/zeropty.h`](https://github.com/CreatiCoding/zeropty/blob/main/bindings/c/include/zeropty.h). ## Rules for every call | Rule | Detail | |---|---| | Requests and replies are JSON | A request is `{"argv": [...], "cwd": "/"}`, the same argv as the CLI. A reply is `{"code": 0, "out": "...", "err": ""}`. | | Who frees a reply | Every `char *` the library returns is freed exactly once with `zeropty_free`, never with `free()`. `zeropty_version` is the exception: a static string. | | Threads | With `zeropty_open`, commands may come from any thread and each runs on the caller's thread. With `zeropty_open_polled`, every call comes from the one thread that runs the loop. | | After close | The handle is invalid after `zeropty_close`: no call on it may run during or after it. | ## Version ### `ZEROPTY_VERSION_*` ```c #define ZEROPTY_VERSION_MAJOR 0 #define ZEROPTY_VERSION_MINOR 1 #define ZEROPTY_VERSION_PATCH 5 #define ZEROPTY_VERSION_PRERELEASE "" #define ZEROPTY_VERSION_STRING "0.1.6" #define ZEROPTY_VERSION_NUMBER (ZEROPTY_VERSION_MAJOR * 1000000 + ZEROPTY_VERSION_MINOR * 1000 + ZEROPTY_VERSION_PATCH) #define ZEROPTY_VERSION_IS_PRERELEASE 0 ``` The version this header belongs to, following [Versioning](/guide/versioning). The values above are those of the current release. | Macro | Meaning | |---|---| | `ZEROPTY_VERSION_MAJOR`, `ZEROPTY_VERSION_MINOR`, `ZEROPTY_VERSION_PATCH` | The three numbers. | | `ZEROPTY_VERSION_PRERELEASE` | The pre-release part (`"rc.1"` in 0.2.0-rc.1), `""` for a release. | | `ZEROPTY_VERSION_STRING` | The whole version as a string. | | `ZEROPTY_VERSION_NUMBER` | One number for `#if`: MAJOR × 1000000 + MINOR × 1000 + PATCH. It leaves the pre-release out, so 0.2.0-rc.1 and 0.2.0 have the same number. | | `ZEROPTY_VERSION_IS_PRERELEASE` | `1` for a pre-release, `0` for a release. `#if` can test it. | Compare the header with the library at run time: a library of another MAJOR (another MINOR before 1.0) may not match these declarations. ### `zeropty_version` ```c const char *zeropty_version(void); ``` The linked library's version, in the same form as `ZEROPTY_VERSION_STRING`. A static string: never free it. ## Engine with its own threads ### `zeropty` ```c typedef struct zeropty zeropty; ``` An engine handle. Opaque: use it only through the functions on this page. ### `zeropty_open` ```c zeropty *zeropty_open(const char *options_json); ``` Starts an engine in this process. | Argument | Meaning | |---|---| | `options_json` | `NULL`, or `{"history": ROWS, "size_dir": "DIR"}`: rows of scrollback per session, and where resized sessions' window sizes are written for their programs. | Returns the handle, or `NULL` on failure. Close it with `zeropty_close`. ```c zeropty *zp = zeropty_open("{\"history\": 2000}"); if (!zp) return 1; ``` ### `zeropty_command` ```c char *zeropty_command(zeropty *a, const char *request_json); ``` Runs one command, as the CLI would. | Argument | Meaning | |---|---| | `a` | The engine. | | `request_json` | `{"argv": ["capture-pane", "-p", "-t", "=x:"], "cwd": "/"}`. | Returns the reply `{"code", "out", "err"}`, never `NULL` for a valid engine. Free it with `zeropty_free`. A plugin's command waits for the plugin: to wait without holding the thread, use [`zeropty_command_start`](#zeropty_command_start). ### `zeropty_free` ```c void zeropty_free(char *reply); ``` Frees a reply of this library: each exactly once, never with `free()`. `NULL` is a no-op. ### `zeropty_listen` ```c int zeropty_listen(zeropty *a, const char *path); ``` Answers commands on a Unix socket at `path` too, as the zeropty server does. `ZEROPTY_SOCK=PATH zeropty send-keys ...`, `attach` and socket clients then reach this engine's sessions with no server process. Returns `0`, or `-1` if it could not listen there (a server already answers on it, the path is too long, ...). A polled engine serves the socket in `zeropty_process`. ### `zeropty_close` ```c void zeropty_close(zeropty *a); ``` Stops the engine. Sessions keep running until their programs end: send `kill-server` first to end them. The handle is invalid afterwards, and no call on it may run during or after `zeropty_close`. ## Engine run by your event loop These four functions give an engine with no thread of its own: your loop runs it. Every call comes from that one thread. ```c zeropty *a = zeropty_open_polled(NULL); for (;;) { poll({zeropty_fd(a), POLLIN}, zeropty_timeout_ms(a)); zeropty_process(a); } ``` ### `zeropty_open_polled` ```c zeropty *zeropty_open_polled(const char *options_json); ``` Like [`zeropty_open`](#zeropty_open), with the same `options_json`, but the engine has no thread. The other calls on the handle (`zeropty_command`, `zeropty_listen`, `zeropty_close`, ...) work as before, from the loop's thread. ### `zeropty_fd` ```c int zeropty_fd(zeropty *a); ``` A file descriptor to wait on for reading. When it is readable, call `zeropty_process`. `-1` for an engine with its own thread. ### `zeropty_timeout_ms` ```c long long zeropty_timeout_ms(zeropty *a); ``` Milliseconds until `zeropty_process` is due even with `zeropty_fd` not readable. ### `zeropty_process` ```c void zeropty_process(zeropty *a); ``` Handles what is waiting, without blocking. Call it when `zeropty_fd` is readable or `zeropty_timeout_ms` milliseconds have passed. ## Commands that do not wait for a plugin A plugin's command can take time. These calls start a command and let your loop go on until the reply comes. They work with either kind of engine. ```c zeropty_call *c = zeropty_command_start(a, "{\"argv\":[\"my-plugin-cmd\"],\"cwd\":\"/\"}"); for (;;) { poll({zeropty_fd(a), POLLIN}, zeropty_timeout_ms(a)); zeropty_process(a); char *r = zeropty_call_poll(c); if (r) { ...; zeropty_free(r); break; } } zeropty_call_free(c); ``` The plugin's reply makes `zeropty_fd` readable, and `zeropty_timeout_ms` counts the call's deadline, so the loop never spins. ### `zeropty_call` ```c typedef struct zeropty_call zeropty_call; ``` A command in flight. Opaque. ### `zeropty_command_start` ```c zeropty_call *zeropty_command_start(zeropty *a, const char *request_json); ``` Starts a command with the same `request_json` as [`zeropty_command`](#zeropty_command). A plugin's command comes back in flight; any other command (and a bad request) is answered already. Free the call with `zeropty_call_free`. ### `zeropty_call_poll` ```c char *zeropty_call_poll(zeropty_call *c); ``` `NULL` while the call is pending. Otherwise the reply JSON, once: the plugin's reply, or the failure once the plugin stopped or the deadline passed. Free it with `zeropty_free`. ### `zeropty_call_due_ms` ```c long long zeropty_call_due_ms(zeropty_call *c); ``` Milliseconds until the call's deadline, when `zeropty_call_poll` gives up on the plugin. `-1` for `NULL`. ### `zeropty_call_free` ```c void zeropty_call_free(zeropty_call *c); ``` Frees the call. Freed before its reply, a call the plugin did not start on is never run. ## Plugins in this process A C function can hear this engine's sessions and answer commands it adds. For writing one, see [Write a plugin](/guide/plugins). For the messages and access rules, see [Plugin API](/reference/plugin-api). ### `zeropty_host` ```c typedef struct zeropty_host zeropty_host; ``` What the plugin's callback uses to run commands. Valid only during the call it was passed to. ### `zeropty_plugin_fn` ```c typedef char *(*zeropty_plugin_fn)(void *user, const char *message_json, zeropty_host *host); ``` The plugin's callback. It runs on the plugin's own thread, never the engine's, with one JSON message each time: | Message | When | |---|---| | `{"start":{"api":1}}` | First. | | `{"event":"session-exited","session":"w","reason":"killed"}` and other events | For the events the manifest asks for. | | `{"call":1,"argv":["hello","x"],"cwd":"/"}` | A call of one of its commands. | | `{"stop":true}` | Last. | For a call it returns the reply `{"code":0,"out":"...","err":""}` in memory from `malloc` (zeropty frees it), and `NULL` otherwise. A callback that hangs past the deadline (10 s) is cut off, and one that is slow loses events: the engine is never held up by it. ### `zeropty_add_plugin` ```c char *zeropty_add_plugin(zeropty *a, const char *manifest_json, zeropty_plugin_fn callback, void *user); ``` Adds a plugin to this engine. | Argument | Meaning | |---|---| | `manifest_json` | `{"name":"hello","events":["session-created","output"],"commands":["hello"],"access":["input"],"api":1}`. `events` may be a list or a comma-separated string (`"session-created,output"`). `access` is what `zeropty_host_command` may run: `"read"` by default, `"input"`, `"manage"`, `"call"` or `"all"`. It may be left out. | | `callback` | The [`zeropty_plugin_fn`](#zeropty_plugin_fn). | | `user` | Passed back to every call of `callback`. | Returns `NULL` when added; otherwise why not, freed with `zeropty_free`. ### `zeropty_host_command` ```c char *zeropty_host_command(zeropty_host *host, const char *request_json); ``` Runs a command from inside the callback, with the same `request_json` as [`zeropty_command`](#zeropty_command). Safe with either kind of engine: it goes through the engine's loop. Returns the reply JSON, freed with `zeropty_free`. ## Plugin libraries A plugin library is a `.dylib` built with this header alone (no `-lzeropty_c`), with one entry point: ```c int zeropty_plugin_init(const zeropty_plugin_api *api, zeropty_plugin_def *def); ``` It fills `def` and returns `0` (anything else: not loaded), and keeps `api`. The same library runs in a process of its own attached to the server (`zeropty plugin-run plugin.dylib`, or from the plugin directory of a server started with `ZEROPTY_PLUGIN_DIR`), or in this process through [`zeropty_load_plugin`](#zeropty_load_plugin). ### `ZEROPTY_PLUGIN_API` ```c #define ZEROPTY_PLUGIN_API 1 ``` The plugin API version this header describes. ### `zeropty_plugin_api` ```c typedef struct zeropty_plugin_api { unsigned int api; unsigned int size; char *(*host_command)(zeropty_host *host, const char *request_json); void (*free)(char *s); } zeropty_plugin_api; ``` | Field | Meaning | |---|---| | `api` | The host's plugin API version. | | `size` | `sizeof` what the host passes. Later versions only add fields at the end. | | `host_command` | [`zeropty_host_command`](#zeropty_host_command) for this plugin. | | `free` | Frees what `host_command` returns. | ### `zeropty_plugin_def` ```c typedef struct zeropty_plugin_def { const char *manifest_json; zeropty_plugin_fn callback; void *user; } zeropty_plugin_def; ``` | Field | Meaning | |---|---| | `manifest_json` | As for [`zeropty_add_plugin`](#zeropty_add_plugin). Valid while the library is loaded. | | `callback` | The plugin's [`zeropty_plugin_fn`](#zeropty_plugin_fn). | | `user` | Passed back to every call of `callback`. | ### `zeropty_plugin_init_fn` ```c typedef int (*zeropty_plugin_init_fn)(const zeropty_plugin_api *api, zeropty_plugin_def *def); ``` The type of the entry point `zeropty_plugin_init`. ### `zeropty_load_plugin` ```c char *zeropty_load_plugin(zeropty *a, const char *path); ``` Loads a plugin library into this engine's process. A crash in the library is this process's crash. Returns `NULL` when added; otherwise why not, freed with `zeropty_free`. --- <!-- https://zeropty.pubdoc.dev/reference/commands --> # Commands Every command and option of the `zeropty` CLI. The same commands work over the socket and through the embedding APIs. ## CLI at a glance ```sh zeropty new-session -d -s work -x 220 -y 80 -c ~/proj "$PWD/dist/claude-zeropty" # starts the server if none is running zeropty capture-pane -p -e -S -200 -t '=work:' # screen + 200 lines of scrollback, with colors zeropty wait-pane -t '=work:' -m '>' -q 1000 -T 60000 # until '>' appears and the session is quiet for 1s (60s max) zeropty send-keys -t '=work:' -l 'hello' && zeropty send-keys -t '=work:' Enter zeropty attach -t work # attach to watch as a human; detach with Ctrl-b d zeropty attach -r -t work # read-only (detach with q or Ctrl-c) zeropty kill-session -t '=work:' ``` **Help and version.** `zeropty --help` (or `-h`, `help`) prints the list of commands and options, and `zeropty help <command>` prints that command's usage, what it does and a link to the matching section of this document. Both write to standard output and exit with code 0. Abbreviations (`help send`) work too. The program answers on its own, so no server is needed and none is started. The zsh function and the bash builtin behave the same way. `zeropty -V` (or `--version`) prints the version of the CLI itself (for the version of the running server, use `zeropty display -p '#{version}'`). What counts as compatible is described in [Versioning](/guide/versioning), and what changed is in [Changelog](/guide/changelog). Each option is explained in the [command reference](/reference/commands#command-reference) below. ```sh zeropty --help # list of commands (exit code 0) zeropty help send-keys # one command: usage, what it does, doc link (exit code 2 for an unknown command) zeropty -V # zeropty X.Y.Z (--version does the same) zeropty # usage: zeropty serve | <tmux command> ... (standard error, exit code 2) ``` ## Command reference Names in parentheses are the same aliases tmux uses. `<value>` after an option means the option takes a value. ### Sessions | Command | Options | What it does | |---|---|---| | `new-session` (`new`) `[command]` | `-d` `-s <name>` `-x <cols>` `-y <rows>` `-c <dir>` `-e <VAR=value>` `-A` `-P` `-F <format>` | Creates a session. Without a command it runs `$SHELL` (or `/bin/sh` if unset). The default size is 80×24, and without a name it uses the number of sessions created so far (`0`, `1`, ...). `-A` succeeds and leaves the session alone if one with the same name exists. `-P` prints information about the new session (`-F` sets the format; the default is `name:`). `-e` can be repeated. `-n -E -D -X` are for windows and clients, so they are accepted and ignored | | `has-session` (`has`) | `-t` | 0 if the session exists, 1 if not (`can't find session: name`) | | `kill-session` | `-t` `-a` | Sends SIGHUP to the session's process group, then SIGKILLs any process still alive after 2 seconds. `-a` means every session except the `-t` session. `-C` is accepted and ignored | | `list-sessions` (`ls`) | `-F <format>` | Lists sessions. The default output matches tmux: `name: 1 windows (created ...)` | | `rename-session` (`rename`) `<new name>` | `-t` | Renames a session. An empty name, a name containing `:` or `.`, or a name already in use is an error | | `resize-window` (`resizew`), `resize-pane` (`resizep`) | `-t` `-x <cols>` `-y <rows>` | Resizes the screen. The program receives SIGWINCH | | `pipe-pane` (`pipep`) `[command]` | `-t` `-o` `-O` | Keeps feeding the session's output into the command's input (tmux's `-O` is the default). Calling it without a command closes the pipe. `-o` only closes it if one is already open. Output the command cannot accept yet is buffered up to 8MB, and anything beyond that is dropped. The pipe is closed when the session moves to another process | | `kill-server` | | Sends SIGHUP to every session. A standalone server process also exits. An embedded engine ends only the sessions and keeps running | When the program exits, the session ends too (the same as tmux with `remain-on-exit` off). The server keeps the environment of the process that started it (the first command that starts it: `new-session`, `pull-sessions` or `zeropty serve`), as tmux does: a session gets that environment with `set-environment -g` and `-e` applied, not the environment of the shell running `new-session`. `restart-server` starts the new server from the shell that runs it, so later sessions get that shell's environment, and values set with `set-environment -g` are dropped (set them again); running sessions keep theirs. Environment the program in a session receives: `TERM=xterm-256color`, `COLUMNS`/`LINES`, `ZEROPTY_SESSION` (the session name), `ZEROPTY_COLS`/`ZEROPTY_ROWS`, `ZEROPTY_SIZE_FILE` (the window size file the shim reads), and `ZEROPTY_TTY_ID` (which files are the session's terminal, for the shim). `TMUX` and `TMUX_PANE` are removed (even if the server was started inside tmux, the session is not inside tmux). ### Sending keys | Command | Options | What it does | |---|---|---| | `send-keys` (`send`) `<key>...` | `-t` `-l` `-H` `-N <count>` | Sends keys. For each argument, a [key name](/reference/keys-and-formats#key-names) sends that key's bytes, and anything else is sent as literal text. `-l` sends the text literally without interpreting key names. `-H` reads the arguments as hexadecimal bytes (`-H 41 42 0a` is `AB` plus a newline). `-N` repeats the whole thing that many times | | `paste-buffer` (`pasteb`) | `-t` `-b <buffer>` `-d` `-p` `-r` `-s <separator>` | Sends the buffer's contents as keys. Newlines are sent as `\r` (unchanged with `-r`; `-s` sets the separator). `-p` wraps the text in bracketed paste if the program has enabled it. `-d` deletes the buffer after sending | Keys pass through the [line discipline](https://github.com/CreatiCoding/zeropty/blob/main/docs/USAGE.md#key-input-and-the-line-discipline), just as with a PTY. ### Reading the screen | Command | Options | What it does | |---|---|---| | `capture-pane` (`capturep`) | `-t` `-p` `-e` `-J` `-N` `-S <line>` `-E <line>` `-b <buffer>` `-q` | Reads the screen as text. Pending output is processed right before reading, so the screen is always current. `-e` includes color and font escapes, `-J` joins lines that were split by wrapping, and `-N` keeps trailing spaces. `-S`/`-E` use the same line numbers as tmux (0 is the first screen line, negative numbers are scrollback, `-` means to the end). Without `-p`, as in tmux, the screen goes into a buffer (`-b`, or `buffer0` by default) and nothing is printed | | `display-message` (`display`) `[format]` | `-t` `-p` `-F <format>` | Prints the format with [format variables](/reference/keys-and-formats#format-variables) filled in. Example: `display-message -p -t '=work:' '#{cursor_x},#{cursor_y}'` | ### Waiting and searching These are zeropty commands that tmux does not have. They tell you, without polling, when an agent has finished or which session is waiting for input. | Command | Options | What it does | |---|---|---| | `wait-pane` | `-t` `-m <text>` `-q <ms>` `-T <ms>` | `-m` waits until the text appears on screen (immediately if it is already there), and `-q` waits until the session has printed nothing for that long. With both, it answers once both hold. After `-T` it exits with code 1 (`wait-pane: timed out`), and it also exits with 1 if the session ends while waiting (`wait-pane: session ended`) | | `grep-panes <text>` | `-S <line>` | Searches every session's screen for the text and shows matches as `session: line`. `-S` also searches scrollback, like `capture-pane -S` (`-S -100`). Results are sorted by session name, and the exit code is 1 when nothing matches (like grep) | - `wait-pane` checks only when output arrives at the server, and only the rows that output changed. Text is matched within a row (text split across a line wrap is not found). - The next command on the same connection is handled after `wait-pane` answers. `client/zeropty.mjs` opens a separate connection for each `wait-pane`, so other commands are not blocked. - If a session exits right after printing its last output, the engine may report the session's end before it checks that output, and `wait-pane` then ends with `wait-pane: session ended` (exit code 1). This is a known issue. Keep the session alive after the text you are waiting for appears, for example: `sh -c 'make; echo build done; exec cat'` - `wait-pane` is not available through the embedded engine's function call (there is no loop that could defer the answer). It works over the socket once you enable the [server plugin](/reference/rust-api#server-plugin-your-program-opens-the-socket-too). Rust hosts use `Engine::wait_start` and `wait_check` directly ([`examples/polled.rs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/polled.rs)). ### Buffers | Command | Options | What it does | |---|---|---| | `load-buffer` (`loadb`) `<file>` | `-b <buffer>` | Loads a file's contents into a buffer (`buffer0` by default). Relative paths are resolved against the current directory of the side that sent the command | | `paste-buffer` | | See [Sending keys](/reference/commands#sending-keys) | | `capture-pane -b` | | Without `-p`, puts the screen into a buffer | ### Viewing (attach) | Command | Options | What it does | |---|---|---| | `attach-session` (`attach`, `a`) | `-t` `-r` | Attaches the current terminal to a session. Detach with `Ctrl-b d`. `-r` is read-only; detach with `q` or `Ctrl-c`. Without `-t`, it attaches to the only session if there is one, and asks for a number if there are several | While attached, the session follows your terminal's size, and it returns to its original size when you detach. Several terminals can attach to one session; the size follows the terminal that attached last. ### Environment | Command | Options | What it does | |---|---|---| | `set-environment` (`setenv`) `<VAR> [value]` | `-g` `-u` | Sets an environment variable for sessions created afterwards. `-u` removes it. Without `-g`, even tmux applies the variable only to new windows, and since sessions never get new windows, it does nothing | | `show-environment` (`showenv`) | `-g` | Shows the variables set with `-g`. Removed variables are shown as `-VAR` | ### Server and moving sessions | Command | Options | What it does | |---|---|---| | `serve` | `--bind <socket>` | Starts a standalone server yourself (normally `new-session` starts one for you) | | `pull-sessions` `[name...]` | `-s <socket>` | Brings sessions from another server into this one (all of them if no names are given) | | `push-sessions` `[name...]` | `-s <socket>` | Sends this server's sessions to another server | | `restart-server` | | A new server takes over every session and the socket. Use it for upgrades | Details: [Moving sessions](/reference/socket-protocol#moving-sessions). The internal requests servers use with each other, `export-sessions` and `import-sessions`: [socket protocol](/reference/socket-protocol#socket-protocol-export-sessions-import-sessions) ### Plugins | Command | Options | What it does | |---|---|---| | `list-plugins` | | One line per attached plugin: `name: state (api N) events=... commands=... access=... dropped=N`. The state is `running`, `stopped` or `failed (reason)` | | `plugin-attach` | | Turns the socket connection into a plugin. Only meaningful on the socket | | `plugin-run` `<library.dylib>` | | Loads a C plugin library in a separate process and attaches it to the server. It exits when the server exits | Commands added by plugins are called from the CLI, the socket and the embedded engine just like built-in commands. If you set `ZEROPTY_PLUGIN_DIR`, `zeropty serve` starts the plugins in that directory and restarts them when they exit. **If it is not set (the default), nothing is run.** Pointing at a directory is consent to run the code in it as the same user as the server, so this is off by default (the same behavior as before this feature existed). How to attach plugins and the protocol: [Plugin API](/reference/plugin-api) ### Diagnostics | Command | What it does | |---|---| | `stats` | One line of engine counters: `sessions`, `heap_bytes` (bytes requested from the allocator), `screen_bytes`, the number of `live`/`frozen` screens, the number of reads, and more | | `mstats` | Allocator (mimalloc) statistics. Only on a standalone server (the command counts that process's allocator) | | `mem -t <session>` | Memory breakdown of one screen. For a frozen screen: `frozen <bytes> B deep <true/false>` | --- <!-- https://zeropty.pubdoc.dev/reference/environment --> # Environment variables Every environment variable zeropty reads. ## Environment variables | Variable | Default | Meaning | |---|---|---| | `ZEROPTY_SOCK` | `~/.zeropty/mux.sock` | Server socket (path of 103 bytes or less) | | `ZEROPTY_HISTORY` | 500 | Number of scrollback lines | | `ZEROPTY_COALESCE_MS` | 300 | Interval for batching output reads. Once 16KB or more has piled up it is read immediately, and screen reads always process pending output first. Attached terminals get output right away | | `ZEROPTY_FREEZE_MS` | 250 | After this much quiet, the whole screen is frozen | | `ZEROPTY_COLD` | file | Where compressed copies of long-quiet screens are kept. `memory` keeps them in process memory | | `ZEROPTY_COLD_DIR` | temp directory | Directory where the compressed-copy file is created (its name is unlinked right after creation) | | `ZEROPTY_RECORD` | – | Directory to record session output into as `<name>.raw`/`.trace` | | `ZEROPTY_PURGE_MS` | 0 | Standalone server: how long to wait before returning freed memory to the OS | | `ZEROPTY_BIN` | `dist/zeropty` | Program run by the Node client, the zsh function and the bash builtin | | `ZEROPTY_NODE` | `dist/zeropty.node` | Native module loaded by the Node client | | `ZEROPTY_SHIM` | `dist/shim.dylib` | Shim injected by `zeropty-tty` | | `ZEROPTY_CACHE` | `~/.zeropty` | Where `zeropty-tty` keeps re-signed copies (under `tty/`) | | `ZEROPTY_CLAUDE_REAL` | `claude` on `PATH` | The claude that `claude-zeropty` launches | | `ZEROPTY_PLUGIN_DIR` | (unset: off) | Plugin directory the server launches. Only when set are the executables and `.dylib` files in it run. A server started by `restart-server` or `new-session` takes the value from the shell that ran that command ([PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#autostart-the-plugin-directory)) | | `ZEROPTY_PLUGIN_TIMEOUT_MS` | 10000 | Deadline for plugin commands and hooks. A plugin's `Host::command` waits up to half of it | --- <!-- https://zeropty.pubdoc.dev/reference/ --> # Reference Exact, complete descriptions of every command, option, protocol and API. The reference says what each thing is. For how to use it, with steps and examples, see the [Guide](/guide/introduction). The command-line pages and the socket and plugin protocols are built from the repository's `docs/` each time the site is built, so they always match the code on `main`. The C ABI and Node.js pages are checked against the build's list of public symbols (`api/*.txt`). ## Command line | Page | What it covers | |---|---| | [Commands](/reference/commands) | Every command and option, grouped as sessions, sending keys, reading the screen, waiting and searching, buffers, viewing, environment, server, plugins and diagnostics. | | [Keys, formats and exit codes](/reference/keys-and-formats) | Key names for `send-keys`, format variables for `-F` and `display-message`, and exit codes. | | [Environment variables](/reference/environment) | Every variable zeropty reads, with its default. | | [tmux compatibility](/reference/tmux-compatibility) | What zeropty does and does not do compared with tmux. | ## From programs | Page | What it covers | |---|---| | [Socket protocol](/reference/socket-protocol) | The JSON lines on the server's socket, and moving sessions between servers. | | [C ABI](/reference/c-abi) | Every function, type and macro of `libzeropty_c.dylib` and `zeropty.h`. | | [Node.js API](/reference/node-api) | Every export of `client/zeropty.mjs`, `client/embedded.mjs` and `zeropty.node`. | | [Rust API](/reference/rust-api) | The Rust crate: the engine in your process, the server-plugin variant, plugins in Rust, and how to build the rustdoc. | | [Plugin API](/reference/plugin-api) | Access rules, the plugin socket protocol, events, commands and the Rust API of `zeropty::plugin`. | ## Elsewhere | Where | What | |---|---| | [Guide](/guide/introduction) | Concepts and task guides. | | [Benchmarks](/guide/benchmarks) | Every measurement and how it was taken. | | [Changelog](/guide/changelog) | Changes, release by release. | | [`llms.txt`](/llms.txt), [`llms-full.txt`](/llms-full.txt) | This site as plain text, for tools. | | `zeropty --help`, `zeropty help COMMAND` | The same command reference, offline. | --- <!-- https://zeropty.pubdoc.dev/reference/keys-and-formats --> # Keys, formats and exit codes Key names for `send-keys`, format variables for `-F`, and the exit codes of every command. ## Key names `Enter`, `Escape`, `Tab`, `BTab`, `BSpace`, `Space`, `Up`, `Down`, `Left`, `Right`, `Home`, `End`, `PageUp` (`PgUp`, `PPage`), `PageDown` (`PgDn`, `NPage`), `IC` (`Insert`), `DC` (`Delete`), `F1`\~`F12`. Combine them with the prefixes `C-` (Ctrl), `M-` (Alt) and `S-` (Shift) (`C-c`, `M-x`, `S-Up`; `^c` also works). Names are case-insensitive. The bytes sent were checked against tmux 3. Arguments that are not key names are sent as literal text. When a program turns on application cursor mode (vim and others), the arrow keys switch to the `ESC O A` form, the same as tmux. ## Format variables Used by `list-sessions -F`, `new-session -P -F` and `display-message`. | Variable | Meaning | |---|---| | `session_name`, `session_id`, `session_created`, `session_attached`, `session_windows` | Session name, id starting with `$` (`pane_id` starts with `%`), creation time (Unix seconds), number of attached terminals, number of windows (always 1) | | `window_index`, `window_id`, `window_name`, `window_width`, `window_height`, `window_panes` | Window information (there is always exactly one window) | | `pane_id`, `pane_index`, `pane_pid`, `pane_width`, `pane_height` | Pane information and the program's pid | | `pane_current_command`, `pane_current_path` | Name and directory of the command currently running | | `pane_dead`, `pane_in_mode` | Always `0` (for tmux script compatibility; `window_index` and `pane_index` are always `0` too) | | `cursor_x`, `cursor_y`, `alternate_on` | Cursor position (from 0) and whether the alternate screen is in use | | `history_size`, `history_limit` | Number of scrollback lines and the limit | | `version` | zeropty version of the answering server (or embedded engine) | These were checked against tmux on the same sessions. A format that uses only `#{version}` works without a session (`zeropty display -p '#{version}'` gives the running server's version; `zeropty -V` gives the CLI's own version). When `-t` names a session that does not exist, `display-message` prints the format with the session's variables empty and exits 0, as tmux does; to test whether a session exists, use `has-session`. Unknown variables become an empty string, as in tmux, and conditionals (`#{?...}`) and modifiers (`#{t:...}`) are not supported. ## Exit codes | Code | Meaning | |---|---| | 0 | Success | | 1 | Failure: missing session (except `display-message -t`, see [format variables](/reference/keys-and-formats#format-variables)), unknown option, `wait-pane` timeout or session end, "not found" from `grep-panes` or `has-session`, no server (`no server running on ...`) | | 2 | Usage error: run without arguments, `zeropty help <unknown command>`, unknown argument to `attach` | --- <!-- https://zeropty.pubdoc.dev/reference/node-api --> # Node.js API Every export of zeropty's Node.js modules. For a first program and examples, see [Use zeropty from Node.js](/guide/node). The build checks this list: `api/node.txt` is compared with the modules, and the site's build fails if an export in it is missing here. Supported Node.js: N-API 6 or later (`napi6`). | Module | What it is | When to use it | |---|---|---| | [`client/zeropty.mjs`](#clientzeroptymjs) | A client of a running zeropty server, over its socket | The sessions live in the zeropty server, shared with the CLI. | | [`client/embedded.mjs`](#clientembeddedmjs) | The engine inside your Node process | Your program owns the sessions, with no server process. | | [`zeropty.node`](#zeroptynode) | The native module both of them use | You need a class the JS modules do not wrap. | The two JS modules export the same `zeropty` and `zeroptySync`, so switching between them takes only a change of import. ## The result shape ```ts { ok: boolean, code: number, out: string, err: string } ``` A command's result, as the CLI's exit status and output: `code` is the exit code, `ok` is `code === 0`, `out` and `err` are standard output and standard error. ## `client/zeropty.mjs` A client of the zeropty server. One connection to the server's socket stays open, and commands go over it one line each, replies coming back in order. The argv is the CLI's, so `tmux(...args)` call sites become `zeropty(args)`. ### `zeropty` ```ts zeropty(argv: string[], options?: { cwd?: string, sock?: string, timeout?: number }): Promise<Result> ``` Runs one command and resolves its [result](#the-result-shape). | Argument | Default | Meaning | |---|---|---| | `argv` | | The command line, its name first. | | `options.cwd` | `process.cwd()` | The working directory the command sees. | | `options.sock` | [`sockPath()`](#sockpath) | The server's socket. | | `options.timeout` | `15000` | Milliseconds to wait for the reply. `wait-pane` has no timeout unless one is given here (its own `-T` sets the server's). | A command the program runs itself rather than the server (`restart-server`, `serve`, `attach`, `-V`) runs the CLI. ### `zeroptySync` ```ts zeroptySync(argv: string[], options?: { cwd?: string, sock?: string }): Result ``` The same, synchronously: the command is written and its reply read on this thread, blocking, with no event loop turn in between. It is faster than `zeropty` when Node is otherwise idle, and blocks the thread for the command's time (about 0.1 ms). Needs `dist/zeropty.node`. With no server running, the CLI runs, and it starts a server on `new-session`. ### `sockPath` ```ts sockPath(): string ``` The server's socket: `$ZEROPTY_SOCK`, or `~/.zeropty/mux.sock`. ### `cliPath` ```ts cliPath(): string ``` The CLI that commands the server cannot run go to: `$ZEROPTY_BIN`, or `dist/zeropty` next to the module. ### `VERSION` ```ts VERSION: string ``` This module's version, following [Versioning](/guide/versioning). ## `client/embedded.mjs` The engine inside your process, with no server process. The native module is loaded from `$ZEROPTY_NODE`, or `dist/zeropty.node` next to the module. ### `open` ```ts open(options?: { history?: number, sizeDir?: string, polled?: boolean }): Engine ``` Starts the engine now; otherwise the first command does. Returns the [`Engine`](#engine). | Option | Default | Meaning | |---|---|---| | `history` | | Rows of scrollback per session. | | `sizeDir` | | Where resized sessions' window sizes are written for their programs. | | `polled` | `true` | Node's event loop runs the engine, with no thread of its own: about 0.3 MB less, and output is parsed on the JS thread a slice at a time. `false` gives the engine a thread, for commands from worker threads. | ### `zeropty` ```ts zeropty(argv: string[], options?: { cwd?: string }): Promise<Result> ``` Runs one command and resolves its [result](#the-result-shape). A plugin's command is waited for without holding the JS thread, so other work, and a plugin attached to this process through `listen`, go on meanwhile. ### `zeroptySync` ```ts zeroptySync(argv: string[], options?: { cwd?: string }): Result ``` The same, returning at once: an engine command does not wait on anything. A plugin's command holds the JS thread until it answers, up to its deadline: use `zeropty` for those. ### `listen` ```ts listen(sockPath: string): void ``` Answers commands on a Unix socket at `sockPath` too, as the zeropty server does. The zeropty CLI (`ZEROPTY_SOCK=SOCKPATH zeropty send-keys ...`), `attach` and `client/zeropty.mjs` then reach this process's sessions, with no server process. Throws if a server already answers there. ### `version` ```ts version(): string ``` The loaded `zeropty.node`'s version. It should equal `VERSION`; a `dist/` built from another checkout may not. ### `VERSION` ```ts VERSION: string ``` This module's version. ## `zeropty.node` The native module. Load it with `createRequire`: ```js [app.mjs] import { createRequire } from 'node:module' const { Engine, Client, version } = createRequire(import.meta.url)('./dist/zeropty.node') ``` ### `version` ```ts version(): string ``` The engine's version, the same as `zeropty --version` and the C ABI's [`zeropty_version()`](/reference/c-abi#zeropty_version). ### `Engine` zeropty's engine inside this process. `client/embedded.mjs` wraps one. ```ts new Engine(options?: { history?: number, sizeDir?: string, polled?: boolean }) ``` An engine with no sessions, run by Node's loop unless `polled` is `false`. The options are those of [`open`](#open). ```js const engine = new Engine({ history: 2000 }) ``` | Method | Returns | Meaning | |---|---|---| | `command(argv: string[], cwd?: string)` | `{ code, out, err }` | Runs one command synchronously, with no server round trip (`send-keys` p50 0.06–0.07 ms from Node, measured in [Benchmarks](/guide/benchmarks)). Waits for a plugin's command, up to its deadline. | | `commandStart(argv: string[], cwd?: string)` | `Promise<{ code, out, err }>` | The same without holding the JS thread while a plugin answers. An engine command's promise is already resolved; a plugin's resolves when its reply comes. | | `listen(path: string)` | | Answers commands on a Unix socket at `path` too, as the zeropty server does. Polled, its connections are served on Node's loop. | ```js const { code, out } = engine.command(['capture-pane', '-p', '-t', '=w:']) engine.listen(`${process.env.HOME}/.zeropty/app.sock`) // then: ZEROPTY_SOCK=... zeropty attach -t w ``` ### `Client` A connection to a zeropty server, used synchronously: each command is written and its reply read on the calling thread, blocking, with no event loop in between. `zeroptySync` in `client/zeropty.mjs` uses one. ```ts new Client(sock: string) ``` `sock` is the server's socket path. It connects on the first command. | Method | Returns | Meaning | |---|---|---| | `command(argv: string[], cwd?: string)` | `{ code, out, err }` | Runs one command on the server. Throws if there is no server, or it went away. | | `close()` | | Closes the connection. The next command opens another. | ```js const c = new Client(`${process.env.HOME}/.zeropty/mux.sock`) console.log(c.command(['list-sessions']).out) c.close() ``` --- <!-- https://zeropty.pubdoc.dev/reference/plugin-api --> # Plugin API Access rules, the socket protocol for plugins, events, commands and the Rust API of `zeropty::plugin`. ## Access The commands a plugin can run through `Host::command` (the `id` command over the socket, `zeropty_host_command` in C) are limited to the access it declared at registration. The default is view-only (`read`). | Access | Rust | Allowed commands | |---|---|---| | `read` (default) | `Access::READ` | `capture-pane`, `display-message`, `has-session`, `list-sessions`, `grep-panes`, `show-environment`, `stats`, `mem`, `list-plugins` | | `input` | `Access::INPUT` | `send-keys`, `paste-buffer`, `load-buffer` | | `manage` | `Access::MANAGE` | `new-session`, `kill-session`, `rename-session`, `resize-*`, `set-environment`, `pipe-pane` (runs a program), and any other command not listed above | | `call` | `Access::CALL` | Commands added by other plugins | | `all` | `Access::ALL` | All of the above | - Where to declare it: Rust `Manifest::new("x").access(Access::INPUT)`, socket `plugin-attach ... -a input,call`, C and library manifests `"access":["input"]`, Node `attachPlugin({ access: ['input'] })`. - A command outside the plugin's access is not run and is answered with `send-keys: plugin x lacks access input (...)`. `access=` in `list-plugins` shows each plugin's access. - Not allowed under any access level: `kill-server`, `pull-sessions`, `push-sessions` (handing sessions over holds the engine thread), `attach`, `plugin-attach`, and the plugin's own commands. - Access is a boundary that makes a plugin's intent visible and prevents mistakes. An out-of-process plugin running as the same user can also connect to the socket as an ordinary client, so the security boundary is the socket (accessible only to this user). ## 1. From another process: the socket protocol (any language) You can attach whenever a socket is open, whether it is `zeropty serve` or an embedded engine that called `listen`. One connection is one plugin. Every message is a single line of JSON (terminated by `\n`). **Step 1: connect.** Connect to `ZEROPTY_SOCK` (or `~/.zeropty/mux.sock` if unset) as a Unix socket. These are the same rules as the CLI. **Step 2: attach.** Send `plugin-attach` as the first command. ```json {"argv":["plugin-attach","-n","hello","-e","session-created,session-exited","-c","hello"],"cwd":"/"} ``` | Option | Meaning | |---|---| | `-n name` | Plugin name (required). Letters, digits, `- _ . :`, up to 64 characters | | `-e list` | Events to receive, comma-separated. `sessions` (create, exit, rename) and `all` also work. If omitted, no events are delivered | | `-c list` | Command names to add, comma-separated. Built-in command names and names owned by other plugins cannot be used | | `-a list` | Access levels (table above), comma-separated. Defaults to `read` | | `-v version` | The API version this plugin was written for (default 1). Rejected if higher than zeropty's | The reply is a line like any ordinary command reply. On success it is `{"code":0,"out":"zeropty-plugin 1\n","err":""}`, and by the time this reply arrives, registration is already complete. On failure it is `code` 1 with the reason (the connection stays a client, so you can fix the request and send it again). **Step 3: exchange messages.** After attaching, the following messages flow over this connection. The first key identifies the message type. | Direction | Message | What to do | |---|---|---| | zeropty → plugin | `{"event":"session-created","session":"w"}` | An event (table below) | | zeropty → plugin | `{"call":7,"argv":["hello","you"],"cwd":"/"}` | An added command was called. Reply with the same number | | plugin → zeropty | `{"reply":7,"code":0,"out":"hello\n","err":""}` | Reply to a `call` | | plugin → zeropty | `{"id":3,"argv":["capture-pane","-p","-t","=w:"],"cwd":"/"}` | Run a zeropty command (within access) | | zeropty → plugin | `{"id":3,"code":0,"out":"...","err":""}` | Reply to that command (in the order sent) | - You may send `id` commands while answering a `call` (for example, `hello.py` gets the session list with `list-sessions` and puts it in its reply). While you wait for that reply, events or other `call`s may arrive first, so queue them in the order received and handle them afterwards. - `wait-pane` cannot be used from plugins. Use events for waiting. - Closing the connection removes the plugin from the registry, and its commands become `unknown command` again. When the server exits, the plugin gets EOF. - Socket writes by zeropty have a deadline (`reply_wait`, 7/8 of the deadline). If a plugin stops reading and holds out, only that plugin is marked failed. - Reply to a `call` within 7/8 of the deadline (8.75 seconds by default). If you are late, only that call fails with `plugin timed out` and the late `reply` is discarded. The plugin stays attached. For long-running work, reply "started" first and let the result be fetched with another command. **Python, standard library only:** [`examples/plugins/hello.py`](https://github.com/CreatiCoding/zeropty/blob/main/examples/plugins/hello.py) is the whole protocol (68 lines). ```sh python3 examples/plugins/hello.py & zeropty hello you ``` **Node:** `attachPlugin` in [`client/plugin.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/client/plugin.mjs) handles connecting, line splitting and matching reply numbers. ```js import { attachPlugin } from './client/plugin.mjs' const plugin = await attachPlugin({ name: 'notes', events: ['session-exited'], access: [], // view only. Use ['input'] if you need to send keys onEvent: (ev) => console.log(`${ev.session} ${ev.reason}`), commands: { // Return a string for output, or { code, out, err }. An exception becomes code 1 with its message. 'notes-count': async (argv, { plugin }) => { const { out } = await plugin.command(['list-sessions', '-F', '#{session_name}']) return `${out.split('\n').filter(Boolean).length}\n` }, }, }) ``` [`examples/plugins/notify.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/plugins/notify.mjs) adds a log of session creation and exit (`zeropty notify-log`) and a finder for quiet sessions (`zeropty notify-idle 10`), and with `--desktop` it shows a macOS notification when a session ends. ## Events | Name (`-e`) | Rust | Fields | When | |---|---|---|---| | `session-created` | `Event::SessionCreated` | `session` | `new-session`, or when taken over from another server | | `session-exited` | `Event::SessionExited` | `session`, `reason`: `exited` · `killed` · `handed-over` | When the program exits (`kill-server` is also `exited`), `kill-session`, or when handed over to another server | | `session-renamed` | `Event::SessionRenamed` | `session` (new name), `from` | `rename-session` | | `output` | `Event::Output` | `session`, `data` (socket and C: base64) | Every time bytes output by the program are read. They are read in batches together with the screen, so for quiet sessions this is in units of \~300ms. A single event holds at most 64KB, so a large read arrives as several events. A UTF-8 character can be split across two events | | `activity` | `Event::Activity` | `session` | Every time reading output changes the screen. It copies no bytes, so it is cheaper than `output` | | `sessions` | `Events::SESSIONS` | | The three lifecycle events above | | `all` | `Events::ALL` | | Everything | The engine copies the bytes it reads only when some plugin wants `output`. If you only need to know that the screen changed, use `activity` and read with `capture-pane` when needed. - **`output` can be lossy.** Because of the memory budgets (16MB per handoff from the engine, 8MB per plugin queue), even a plugin that reads promptly loses part of a very large burst of output (tens of MB or more). If you need every byte of output, use `pipe-pane` (which sends session output as-is to a file or command) instead of a plugin. - **`dropped=` is a single total.** Events dropped because the queue was full (1024 events or 8MB of output), output dropped because the engine exceeded its 16MB budget, and events dropped because the engine-side queue overflowed are all added to the same number. They are not counted separately by reason. ## Commands - `list-plugins`: one line per plugin, `name: state (api N) events=... commands=... access=... dropped=N`. Works over the socket, the CLI and embedded calls. There are three states. - `running`: receiving events and commands. - `stopped`: being removed. Shown briefly after the connection closed or the plugin was `remove`d but before the host loop has taken it off the list. Commands are no longer routed to it. - `failed (reason)`: a panic (`panicked in hook: message`), a hook that exceeded the deadline (`stuck in a hook for more than N ms`), or a connection error (`connection: ...`). It stays on the list so you can see the reason, and gives up its slot when a plugin reattaches under the same name. Only the 16 most recent failed entries are kept, and the oldest are removed first (so a program that keeps reattaching under new names cannot grow the registry without bound). - `plugin-attach`: section 1 above. Meaningful only on a socket connection (sent from the CLI, the connection is closed right after the reply, so the plugin is removed immediately). - `zeropty plugin-run library.dylib`: see [Write a plugin](/guide/plugins). Exits when the server exits. ## API reference (Rust, `zeropty::plugin`) The stability boundaries match the module and file boundaries of the code. | File | Path | Used by | Stability | |---|---|---|---| | `src/plugin/api.rs` | `zeropty::plugin::api` (= `zeropty::plugin::*`) | Plugin authors | Stable API (versioning below) | | `src/plugin/registry.rs`, `src/plugin/hosting.rs` | `Plugins`, `Call` | Hosts that embed the engine | Public methods are stable API | | `src/host.rs` | `HostLoop::set_plugins`, `impl Service for Plugins` | Hosts with their own loop | Same as the host loop API (`zeropty::host`) | | `src/plugin/__private/{remote,native,launch}.rs`, `src/plugin/wire.rs` | `zeropty::plugin::__private` (`#[doc(hidden)]`, not in the docs) | The zeropty binary, C bindings, tests | **Unstable**, outside semver | | `src/engine/tap.rs` | (only `Event` and `ExitReason` are public, via `zeropty::plugin`) | Engine | Queues and methods are crate-internal | **Stable API.** The parts used by plugin authors and by hosts that embed the engine. Within the same `API_VERSION`, things are only added (see versioning below). | Item | Description | |---|---| | `API_VERSION` | The version of this interface (currently 1). `Manifest::api` and `plugin-attach -v` are compared against it | | `trait Plugin: Send + 'static` | `manifest()` (required), `start`, `event`, `command`, `stop` (with default implementations). The four hooks (`start`, `event`, `command`, `stop`) are called one at a time on the plugin thread. `manifest()` is called once at registration, first thing on the plugin's new thread, and `add` waits for its answer only until the manifest deadline (the registry deadline, at most 1 second). If it takes longer, registration is rejected with `PluginError::ManifestTimeout(ms)`, and the plugin is dropped on its own thread as soon as `manifest()` finishes. So even a stuck `manifest()` holds the caller of `add` for at most 1 second, and never holds the engine. Build it directly from constants and do slow setup in `start`, which runs under the deadline. A panic is caught and becomes `PluginError::ManifestPanicked` | | `Manifest::new(name).events(Events).command(name).access(Access).api(v)` | Name, events to receive, commands to add, access, version. `events` and `access` add to what is already there. The fields `events: Events` and `access: Access` (which always includes `READ`) are readable. `#[non_exhaustive]` | | `Manifest::from_json(text)`, `attach_argv()` | Parse the JSON manifest of C plugins and libraries; the `plugin-attach` command for the same manifest | | `Event` | `#[non_exhaustive]` enum. `kind() -> Events` (the bit for that single event), `name()`, `session()`, `to_json()` | | `ExitReason` | `Exited`, `Killed`, `HandedOver`. `as_str()`. `#[non_exhaustive]`, so a `match` needs a `_` arm | | `PluginError` | Registration and manifest errors. `#[non_exhaustive]` enum: `BadName`, `BadCommand`, `Api { api, supported }`, `UnknownEvent`, `UnknownAccess`, `BuiltinCommand`, `DuplicateCommand`, `NameTaken`, `CommandTaken { owner }`, `BadManifest`, `ManifestPanicked`, `ManifestTimeout(ms)`, `Thread`, `Unsupported` (a frontend that does not run plugins). `Display` gives a human-readable reason; `String::from` appends a newline, like a reply's `err`. Implements `std::error::Error` | | `Events` | A set of events to receive (typed bit flags). Constants `Events::{SESSION_CREATED, SESSION_EXITED, SESSION_RENAMED, OUTPUT, ACTIVITY, SESSIONS, ALL}`. `Copy`, `Eq`, `Hash`, `Debug` (`Events(session-created \| output)`), `Default` (the empty set), `Display` (the same comma-separated list as the socket). Operators `\|`, `\|=`, `&`, `&=`, `-` (difference), `!` (complement within the known bits). Methods `empty()`, `all()`, `is_empty()`, `contains(other)`, `intersects(other)`, `union`, `difference`, `bits() -> u32`, `from_bits(u32) -> Option<Events>` (`None` if there are unknown bits), `from_bits_truncate`, `parse("a,b") -> Result<Events, PluginError>` (`FromStr` is the same), `names() -> String` | | `Access` | A set of access levels. Constants `Access::{READ, INPUT, MANAGE, CALL, ALL}`. Same traits and methods as `Events` (`parse("read,input")`, `names()`) | | `Host::command(argv, cwd) -> Reply` | Runs an engine command within the plugin's access and waits for the reply (up to half the deadline; time spent waiting does not count toward the hook's time). Commands of other plugins require `CALL`, and calls that would form a cycle are rejected | | `Host::deadline() -> Duration` | The deadline (`set_timeout`, `ZEROPTY_PLUGIN_TIMEOUT_MS`). A hook that runs longer than this on its own time is cut off | | `reply_wait(deadline) -> Duration` | How long a hook may wait on something outside zeropty: the deadline minus 1/8 of it (at least 20ms, at most half). Ensures it gives up clearly before the watchdog fires | | `Host::name()`, `Host::detach()`, `Host::fail(why)` | The name; detach itself; mark itself failed and detach (no commands arrive from that moment on) | | `Reply`, `ok(out)`, `fail(err)`, `Reply::new(code, out, err)` | A command's reply (read its `code`, `out` and `err` fields). Re-exported from the engine module. It is exactly the reply of the CLI and the socket, so the three fields are fixed (you may also build it directly as `Reply { code, out, err }`) | | `is_reserved(name) -> bool` | Whether a name is answered directly by the engine or the host (server, CLI), and so cannot be owned by a plugin. The engine side reads the engine's command table (`COMMANDS`, including aliases) directly, so new engine commands are reserved automatically. The list itself is not API: it grows as zeropty adds commands, so use your own prefix, such as `myplugin-...` | | `Plugins` | The registry. `add`, `add_boxed`, `check` (`Result<(), PluginError>`), `remove` (`false` if absent), `names`, `list`, `set_timeout`, `timeout`, `set_shutdown_wait`, `shutdown`, `attach(&engine)` (lets plugins wake that engine's loop; it takes the engine so that the queue type, currently kqueue, does not leak into the API), `watchdog_ms()` (the longest the loop may sleep while plugins exist). Implements `host::Service`. All public methods are stable API | | `HostLoop::set_plugins(plugins)`, `HostLoop::plugins()` | Makes the host loop run the registry as a service (`zeropty serve`, `Embedded` and `Polled` use this) | | `Embedded::add_plugin`, `add_plugin_boxed`, `Embedded::plugins()`, `Polled::add_plugin`, `add_plugin_boxed`, `Polled::plugins()` | Attach to an embedded engine (`Result<(), PluginError>`). The `embed::Frontend` used by the bindings also has `add_plugin_boxed` and `command_start`. Both have default implementations, so code that implemented `Frontend` before plugins existed still compiles (the default `add_plugin_boxed` is `Err(PluginError::Unsupported)`, and the default `command_start` answers immediately with `command`) | | `Embedded::command_start`, `Polled::command_start` `-> Call` | Call a command without waiting. Engine commands come back as a `Call` that holds the reply | | `Call` | A command in progress. `poll() -> Option<Reply>` (gives the reply once), `wait() -> Reply`, `due_ms()`, `answered()`, `Call::ready(reply)`. Dropping it before the reply means a command not yet started is never run. `Polled::timeout_ms()` only counts the deadlines of `Call`s still waiting for a reply. A `Call` that has been answered or dropped does not wake the loop | **`zeropty::plugin::__private` (not stable API).** The parts used by zeropty's own binary (`plugin-run`, the plugin directory), the C bindings and the tests are gathered in this one module. It is **unstable and outside semver**: it can change or disappear at any time without a version bump. The stable plugin API is only `api` (= `zeropty::plugin::*`), `Plugins` and `Call`, and plugins and hosts do not use `__private`. This follows the same convention as serde's `__private`: it is `#[doc(hidden)]` and does not appear in the API docs, and no item of the stable API takes or returns a type from this module. Using a path with `__` in its name is itself the signal that you are using something unstable. Why it is not hidden behind a cargo feature: the binary (`src/main.rs`) and the bindings are separate crates from the library, so they need a public path, and cargo has no way for a package to enable a feature only for its own binaries. Putting it behind a feature would mean either moving the binary to another package (which breaks `cargo install --path .`) or adding `required-features` (which makes `cargo install` skip the binary), so we chose to keep the install path unchanged. That is why the binary and its tests stay in the root package `zeropty` as before: ```sh cargo install --path . # install the zeropty binary cargo build --release -p zeropty # target/release/zeropty ``` | Item | Description | |---|---| | `remote` (`Remote`, `parse_attach`) | The server side of socket plugins (`plugin-attach`) | | `launch::{Autostart, configured_dir, programs}` | The plugin directory (`ZEROPTY_PLUGIN_DIR`) | | `unsafe native::{load, run}`, `unsafe native::Library::from_parts`, `native::{Api, Def, ENTRY}` | Loading plugin libraries (`zeropty_plugin_init`), `plugin-run`. Loading a library runs its code, hence `unsafe` (conditions under `# Safety`). From other languages, use the C ABI (`zeropty_load_plugin`) or `zeropty plugin-run` | | `call_json`, `reply_json`, `reply_from_json`, `base64` | For implementing the protocol | | `set_engine_events`, `take_engine_events`, `has_engine_events` | Test functions that inspect the engine-side event queue without a registry | Not visible outside the crate: the engine-side queue (`Engine::set_tap`, `take_tap`, `has_tap_events`), the registry's `pump`, `call` and `owns`, and the wakeup event tag (`PLUGIN_TAG`) are `pub(crate)`. C ABI (`bindings/c/include/zeropty.h`): `zeropty_add_plugin`, `zeropty_host_command`, `zeropty_load_plugin`, the library entry point `zeropty_plugin_init` with `zeropty_plugin_api` and `zeropty_plugin_def`, and the non-blocking calls `zeropty_command_start`, `zeropty_call_poll`, `zeropty_call_due_ms`, `zeropty_call_free`. Node (`zeropty.node`): `Engine.commandStart(argv, cwd)` always returns a `Promise<Reply>`. For engine commands, the Promise is already resolved. `Reply` and Promise are never mixed, so you can always just `await`. Registration rules: names and command names use letters, digits and `- _ . :` (up to 64 characters, and may not start with `-`). Built-in commands (names for which `is_reserved` is true) and the names and commands of other running plugins cannot be used. Names of failed or stopped plugins can be reused (reconnecting). Operating limits: plugin queue 1024 events and 8MB of output (output copying resumes once it drains below 256 events and 2MB), 64KB per `output` event, engine-side queue 16384 events and 16MB of output (a single read is also copied up to 16MB), deadline 10 seconds (`Host::command` 5 seconds; `call` replies and socket writes for socket plugins 8.75 seconds), watchdog period 1/4 of the deadline (10ms\~1 second), shutdown wait 1 second, 16 failed entries kept. ## Versioning - A plugin declares the version it was written for (`Manifest::api`, `-v`, `api` in the manifest). If it is newer than zeropty's, registration is rejected. Older versions keep being accepted. - Within a version, things are only added: new hooks come with default implementations, and `Event`, `ExitReason`, `PluginError` and `Manifest` are `#[non_exhaustive]`, so existing plugins still compile when new events, exit reasons, error kinds or fields appear (which is why a `match` on these enums needs a `_` arm). `Reply` is both the reply of the CLI and the socket and a type that embedders build as a struct literal, so it is fixed at the three fields `code`, `out` and `err`. If more needs to be reported, a new type is added alongside it rather than adding fields to `Reply`. Socket plugins just need to ignore unknown keys and unknown `event`s. C's `zeropty_plugin_api` only adds fields at the end and announces them through `size`. - Things that need byte-level compatibility (the message key order, with `event`, `call`, `reply` or `id` first) are pinned by tests. - **`zeropty::plugin::__private` is outside this policy.** It exists for zeropty's own binary, bindings and tests, does not follow semver, and can change or disappear without a version bump. If you use it, the next release may break you. The compiler does not stop you (the same convention as serde's `__private`), so code that uses this name takes that risk on itself. - Events and access are the `Events` and `Access` types, not raw `u32` bits. These types hold only the bits this version knows (`from_bits` returns `None` for unknown bits), so a manifest with unknown bits cannot be built. That is why `PluginError::UnknownEventBits` and `UnknownAccessBits`, which used to reject such manifests, were removed before the first release (they are in a `#[non_exhaustive]` enum, so no `match` breaks from this change). The socket, C and JSON formats (names, comma-separated lists) are unchanged. - The JSON manifest of C plugins and dylibs (`Manifest::from_json`) is strict. Keys other than the five (`name`, `events`, `commands`, `access`, `api`), list values that are not strings, and values of the wrong type are registration errors (`bad manifest: ...`). This prevents a typo such as `"event"` from silently registering a plugin with zero events. ## Behavior model This section details the rules in [What plugin authors need to know](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#what-plugin-authors-need-to-know). The tests that verify each rule are listed in [Tests](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#tests). How the registry, the server and the engine are connected is described in [Plugin internals](/guide/plugins-internals). **Threads** - Plugin code never runs on the engine thread. Each plugin has one dedicated thread, and the hooks (`start`, `event`, `command`, `stop`) are called one at a time on that thread. Hooks may block. - Commands a plugin asks the engine to run (`Host::command`, the `id` command over the socket, `zeropty_host_command` in C) run on the engine thread just like any other command. - The engine thread never blocks on a plugin socket. The success reply to `plugin-attach` and any replies backed up on that connection are sent by the plugin thread, and by the time the success reply goes out, registration is already complete. **Events** - The engine does not create events of kinds that no plugin wants. - Events go to a per-plugin queue (1024 events). They are pushed with `try_send` on each turn of the host loop (`pump`); if the queue is full, the event is dropped and counted in `dropped=` of `list-plugins`. A slow plugin only loses events; it cannot hold up the engine. - Queues are bounded by bytes as well as by count. `output` events are split into pieces of 64KB or less, and each plugin queue holds at most 8MB of output (pieces beyond that are dropped and counted in `dropped=`). The engine also copies at most 16MB of a single read, and counts the rest as one dropped event. So even with a plugin that never reads, the extra memory the server uses stays within these budgets (tens of MB combined). Previously a single event was everything read in one go, so 200MB of `yes` plus one `output` plugin that never read took server memory from 141MB to 656MB. `a_burst_to_a_plugin_that_never_reads_stays_within_the_budgets` pins the server's peak memory (`ri_lifetime_max_phys_footprint`) to within 64MB of the no-plugin case (101MB vs 131MB on this machine). This measurement can be affected by machine load, so it runs only with `ZEROPTY_REQUIRE_ALL=1`; the budgets themselves are always checked by deterministic unit tests (`bursts_come_in_bounded_pieces` in `engine::tap`, and the registry's byte-budget tests). - When every plugin that wants `output` (or `activity`) has a full queue, the engine neither copies output nor creates events, and only counts `dropped=`. As soon as one of those queues drains below a quarter (256 events), copying resumes. Lifecycle events (`session-*`) are never switched off this way; they are counted one by one at the queue. - The engine-side queue also has a limit (16384 events, 16MB of output). Events that overflow because the host calls `pump` rarely are added to `dropped=` of the plugins that wanted that kind. **Commands and deadlines** - A caller of a plugin command that gets no reply within the deadline receives a `plugin timed out` error. - The socket server waits for plugin command replies asynchronously. Only that connection waits; other clients are served as usual. The next command on the same connection is processed after it, to preserve order. - A host that embeds the engine can call in two ways. `command` does not return until the reply or the deadline. `command_start` (C `zeropty_command_start`; Node `commandStart` always returns `Promise<Reply>`) returns immediately, and the reply is fetched later with `Call::poll`. Details are in [Calling plugin commands from the host](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#calling-plugin-commands-from-the-host). - The polled engine's `Polled::command` keeps doing the work of `process` while it waits (reading session output, clients of a `listen`ed socket, engine commands issued by plugins, the watchdog). In between, it sleeps on the engine's kqueue and is woken by the plugin's reply, session output, clients or engine timers. It never spins the CPU while waiting. - `call` replies from socket plugins and `plugin-run` libraries are awaited up to `reply_wait(deadline)` (7/8 of the deadline, 8.75 seconds by default). Past that, only that call fails with `plugin timed out (no reply within N ms; a later reply is ignored)`, and a `reply` that arrives late is discarded. The plugin stays `running`. Writing one line (event, call, reply) to a plugin is also given this same time, once in total. If the socket buffer is full and the write cannot complete within that time, the plugin becomes `failed (connection: not read for N ms)`. Since this happens before the deadline, the watchdog never cuts it off as `stuck in a hook`. (With a time limit on each `write`, macOS reports a `write` that sent even a few bytes as successful, so `write_all` waited out the limit several times and the watchdog cut in first.) - `Host::command` waits up to half the deadline. Past that, it gets `command: no answer within N ms (half the plugin deadline)`. The remaining half is used to answer whoever called this plugin, with the reason. - A command the caller gave up on is never run later. A command not yet started is discarded, and `not run` is appended to the error (`busy; not run` if it was only waiting in the plugin's queue). If it had already started, `started, may still take effect` is appended (`plugin timed out` for plugin commands). Starting and giving up are decided exactly once through a single atomic variable, so a command reported as failed never runs afterwards. The same applies to a `Call` dropped before its reply (C `zeropty_call_free`). - Calls that would form a cycle are rejected immediately. If B calls one of A's commands while A is waiting on one of B's commands (with `call` access), it gets `command: call cycle (b -> a -> b): plugin a is waiting for b`. Longer cycles are handled the same way. Both plugins stay `running`. **Failure isolation** - If a hook panics, only that plugin becomes `failed (panicked in hook: message)`, and the caller gets an error. From the moment it fails, no more commands are routed to that plugin, and the same name can be registered again. - If a single hook runs longer than the deadline on its own time, the plugin becomes `failed (stuck in a hook for more than N ms)` and receives no more events or commands. A Rust thread cannot be terminated from outside, so that thread ends when it returns from the hook. - A hook that never returns holds its thread forever. This is unavoidable (Rust has no way to kill a thread from outside). The engine is still unaffected. The watchdog marks the plugin `failed` and closes its queue, so no more events or commands go to it, and the engine never waits on plugin queues or threads in the first place (it only does `try_send`). Callers of that plugin's commands get an error at the deadline, and `shutdown` detaches the thread and returns once its wait (1 second by default) is over. What remains is one stuck thread and whatever memory the plugin holds, which go away when the process exits. If the plugin needs to restart, it can be registered again under the same name. If this worries you for a given plugin, run it out of process (as a socket plugin or with `plugin-run`), where the OS can terminate a stuck process. - `manifest()` also runs on the plugin thread. `add` starts the plugin thread first, calls `manifest()` there, and waits for the answer only until the manifest deadline (the shorter of the registry deadline and 1 second). Past that, it rejects with `ManifestTimeout` and leaves the name free. When `manifest()` returns, that thread drops the plugin and exits (if it never returns, one thread is left behind, like the stuck hook above). The registry lock is taken only after `manifest()` has answered, so other plugin registrations, commands and the engine are not blocked while waiting. - Only the hook's own time counts. Time spent inside a hook waiting on the engine or another plugin through `Host::command` is excluded. The slow party is judged by its own hook time. - The watchdog runs in the host loop's `pump`. While there is at least one plugin, the host loop never sleeps longer than a quarter of the deadline (10ms\~1 second) (`HostLoop::timeout_ms` takes `Plugins::watchdog_ms` into account; for the polled engine this is in `timeout_ms()`, and for the server and `Embedded` it is in the loop's sleep time). So even on an idle engine, a stuck hook is cut off within 1.25 times the deadline. With no plugins, this cap does not exist either. - When an out-of-process plugin dies, the server just sees a closed connection. That plugin is removed from the registry. **Shutdown** - `Plugins::shutdown` (and dropping `Embedded` or `Polled`) sends `stop` to each plugin and waits for the threads to finish for up to `set_shutdown_wait` (1 second by default; 0 means no waiting). Threads still stuck in a hook at that point are detached, and it returns. Such a thread exits without `stop` once it returns from the hook. - Out-of-process plugins get EOF when the server exits. --- <!-- https://zeropty.pubdoc.dev/reference/rust-api --> # Rust API The Rust crate: the engine inside your process, the server-plugin variant, and plugins written in Rust. For the generated rustdoc, run cargo doc as below. ## Rust API docs The rustdoc of the three crates `zeropty`, `zeropty_c` and `zeropty_node`. They are not published on crates.io (built with `./build.sh`). There is no hosted copy; build and open it from the repository: ```sh cargo doc --no-deps --workspace --open ``` Each crate's front page has an overview and runnable examples, and every public function and type has an example (except the `#[deprecated]` items kept under their old paths). The 200 or so Rust examples are doctests, so `cargo test --release --doc` really runs them all. Every public item is documented (`#![warn(missing_docs)]` in all three crates), and `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace` finishes without warnings. `scripts/check.sh` checks both (rustdoc when not run with `--quick`). Where to start: | I want to | Where to look in rustdoc | |---|---| | Put the engine in my process, send commands from several threads | `embed::Embedded`, `Embedded::command`, `Embedded::with` | | Run it from my host's event loop, with no thread | `embed::Polled`, `Polled::fd`, `Polled::timeout_ms`, `Polled::process` | | Wait for text to appear without polling | `engine::Engine::wait_start`, `wait_check`, `wait_due_ms`, `wait_end` | | Let the CLI and socket clients reach my sessions | `Embedded::listen`, `Polled::listen`, the `server` module | | Move running sessions to another process | `engine::handover::pull`, `push`, `Engine::export_session` | | Write a loop around the engine alone, wait on my own fds in the same queue | `engine::Engine`, `Engine::try_poll`, `Engine::watch`, `host::HostLoop` | | Attach my terminal to a session | `Engine::attach`, `viewer_events`, `zeropty::tty` | | Write a socket client myself | the `protocol` module (`Request`, `reply_line`, `ClientFrame`, `ServerFrame`) | | Test the engine against a fake OS | `engine::Backend`, `Poller`, `Clock`, `Programs`, `Engine::with_backend` | | Write a plugin, attach it to a host | the `plugin` module (`plugin::api`), `Embedded::add_plugin`, `Polled::command_start` | | Use only the terminal emulator | `term::Term`, `Term::freeze`, `Term::export` | | Use it from C or Node | the front pages of the `zeropty_c` and `zeropty_node` crates | ## Inside your process: embedded Put the engine inside your process, with no server. The command format and results are the same as on the socket. The one exception is `wait-pane`, which is not available as a function call (see [above](/reference/commands#waiting-and-searching)). Sessions live only as long as this process does (when the process exits, the programs get EOF). To keep them, hand them to a server before exiting (`push-sessions`, below). **Node:** just change the import. Example: [`examples/node/embedded.mjs`](https://github.com/CreatiCoding/zeropty/blob/main/examples/node/embedded.mjs) ```js import { zeropty, open } from './client/embedded.mjs' // instead of './client/zeropty.mjs' await zeropty(['new-session', '-d', '-s', 'w', 'exec cat']) ``` | Function | What it does | |---|---| | `zeropty(argv, { cwd })`, `zeroptySync(argv, { cwd })` | Same calls and same results as the server client. `send-keys` p50 0.06\~0.07ms, `capture-pane` 0.06\~0.07ms (busy session 0.10\~0.17ms) ([measurements](/guide/benchmarks)) | | `open({ history, sizeDir, polled })` | Starts the engine with options before the first command (if you don't call it, the first command starts the engine with defaults) | | `listen(socket)` | [Server plugin](/reference/rust-api#server-plugin-your-program-opens-the-socket-too) | **C (or any language with an FFI):** the header is [`bindings/c/include/zeropty.h`](https://github.com/CreatiCoding/zeropty/blob/main/bindings/c/include/zeropty.h), and the example is [`examples/c/hello.c`](https://github.com/CreatiCoding/zeropty/blob/main/examples/c/hello.c). ```c #include "zeropty.h" // cc app.c -Idist -Ldist -lzeropty_c zeropty *a = zeropty_open(NULL); // or {"history": 2000, "size_dir": "/tmp"} char *r = zeropty_command(a, "{\"argv\":[\"new-session\",\"-d\",\"-s\",\"x\",\"exec cat\"],\"cwd\":\"/\"}"); zeropty_free(r); // r is {"code":0,"out":"","err":""} zeropty_close(a); ``` | Function | What it does | |---|---| | `zeropty_open(options_json)` | An engine with its own engine thread. Options are `NULL` or `{"history": lines, "size_dir": "dir"}` | | `zeropty_command(a, request_json)` | One command. The answer is a JSON string; release it with `zeropty_free` | | `zeropty_listen(a, path)` | Also open a socket. Returns 0, or -1 on failure | | `zeropty_close(a)` | Stops the engine. Sessions keep running until their programs exit (send `kill-server` first to end them) | | `zeropty_open_polled`, `zeropty_fd`, `zeropty_timeout_ms`, `zeropty_process` | Run the engine from the host's loop, with no engine thread (below) | Commands on a `zeropty_open` engine may be called from any thread. Reading the screen, sending keys and queries are handled right on the calling thread; everything else is handed to the engine thread. **Rust:** the crate is not on crates.io yet, so add it as a git dependency. To stay on one commit, add `rev = "<commit>"`. ```toml # Cargo.toml [dependencies] zeropty = { git = "https://github.com/CreatiCoding/zeropty" } ``` Then use `zeropty::embed::Embedded` (engine thread) or `zeropty::embed::Polled` (host loop). For API docs, run `cargo doc --no-deps --workspace --open` in this repository; for examples, `cargo run --release --example embed` and `cargo run --release --example polled`. ```rust use zeropty::{embed::Embedded, engine::Options}; fn main() { let zp = Embedded::start(Options::default()); let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>(); let r = zp.command(&argv(&["new-session", "-d", "-s", "w", "exec cat"]), "/"); assert_eq!(r.code, 0, "{}", r.err); zp.command(&argv(&["send-keys", "-t", "=w:", "-l", "hello"]), "/"); print!("{}", zp.command(&argv(&["capture-pane", "-p", "-t", "=w:"]), "/").out); // hello zp.command(&argv(&["kill-server"]), "/"); } ``` Building requires macOS and the Xcode Command Line Tools (`zstd-sys` compiles C code). The shim used to launch programs that need a terminal is not part of the crate; for that, use `dist/zeropty-tty` built with this repository's `./build.sh`. **Polled: no engine thread (the Node default; 400 idle sessions go from 0.74MB to 0.45\~0.46MB).** The host's event loop drives the engine. Make every call from that loop's thread. In Node, libuv watches the engine fd, so there is nothing extra to do. ```js open({ polled: false }) // to use an engine thread in Node (when calling from worker threads). Call before the first command ``` ```c zeropty *a = zeropty_open_polled(NULL); for (;;) { // host loop: call zeropty_process when the engine fd is readable or the timeout expires struct pollfd p = { zeropty_fd(a), POLLIN, 0 }; poll(&p, 1, (int)zeropty_timeout_ms(a)); zeropty_process(a); } ``` Commands added by plugins can hold the calling thread while they wait for an answer (10-second deadline by default). To avoid holding the host loop, start them with `zeropty_command_start` and collect the answer with `zeropty_call_poll` after `zeropty_process` (Rust: `Polled::command_start`; Node: `await zeropty(...)`). Engine commands called this way already have their answer ready. Details are in [PLUGINS.md](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#calling-plugin-commands-from-the-host). ### Server plugin: your program opens the socket too Calling `listen(socket)` runs the same server as `zeropty serve` inside your program. The `zeropty` CLI, `zeropty attach`, the zsh function, the bash builtin and `client/zeropty.mjs` can then work with this program's sessions without a separate server process. Connections are handled on the same loop as the engine (the host loop when polled, the engine thread otherwise). ```js import { zeropty, listen } from './client/embedded.mjs' listen(`${process.env.HOME}/.zeropty/myapp.sock`) // throws if another server already answers on this socket ``` ```sh ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty capture-pane -p -t '=w:' # from another terminal ZEROPTY_SOCK=~/.zeropty/myapp.sock zeropty attach -t w ``` ```c if (zeropty_listen(a, "/tmp/myapp.sock") != 0) { /* could not open it */ } ``` - The socket path must be 103 bytes or less. The socket gets 0600 permissions, and newly created directories get 0700. - If a server already answers at that path, `listen` fails (Node throws, C returns -1). A socket file nobody answers on (left behind by a server that exited) is taken over; if a non-socket file is in the way, it fails without deleting it. - `kill-server` from the socket ends only the sessions. Your program keeps running and the socket stays open (a standalone server process exits). - Closing the engine (`zeropty_close`, or dropping Rust's `Embedded`/`Polled`) also removes the socket file. If another server has taken the same path in the meantime, its socket is left alone. - Sessions still end together with your program. To keep them beyond its lifetime, `push-sessions` them to a standalone server before exiting. - If the engine stops or the process exits, the socket file stays behind. Since nobody answers on it, the next `listen` takes it over. - `mstats` is only on a standalone server. ## Plugins in Rust Plugins use only `zeropty::plugin` (`Reply`, `ok` and `fail` are re-exported there too). Plugin code stays the same even if the engine's module layout changes. ```rust use zeropty::embed::Embedded; use zeropty::engine::Options; use zeropty::plugin::{ok, Access, Event, Events, Host, Manifest, Plugin, Reply}; struct Bell; impl Plugin for Bell { fn manifest(&self) -> Manifest { Manifest::new("bell").events(Events::SESSION_EXITED).command("bell-test").access(Access::READ) } fn event(&mut self, event: &Event, _host: &Host) { if let Event::SessionExited { session, reason } = event { eprintln!("\x07{session} {}", reason.as_str()); } } fn command(&mut self, _argv: &[String], _cwd: &str, host: &Host) -> Reply { let r = host.command(&["list-sessions"], "/"); // an engine command, run on the engine thread ok(format!("{} sessions\n", r.out.lines().count())) } } let e = Embedded::start(Options::default()); e.add_plugin(Bell)?; // events are delivered from the moment this line returns e.command(&["bell-test".into()], "/"); // callable the same way from the socket (listen) and the CLI ``` - `Polled::add_plugin` works the same way. The polled engine keeps running even while waiting for a plugin command. To avoid holding the host thread, use `command_start` ([Calling plugin commands from the host](https://github.com/CreatiCoding/zeropty/blob/main/docs/PLUGINS.md#calling-plugin-commands-from-the-host)). - If a hook waits on something outside zeropty (another process, the network), wait no longer than `zeropty::plugin::reply_wait(host.deadline())`. Then, if you run late, only that call fails and the watchdog does not cut off the plugin (the server side of socket plugins does exactly this). - Try it: `cargo run --release --example plugin_activity` (an `activity` command that counts the bytes each session has output). Pass a socket path as an argument and it stays up, so you can call it with `ZEROPTY_SOCK=path zeropty activity`. - A host with its own loop (a program that drives `Engine` directly) attaches the registry to `host::HostLoop` as a service. It takes three lines, and `src/cli/serve.rs` does exactly this. ```rust let plugins = Plugins::new(); plugins.attach(&engine)?; // lets plugins wake the loop (one user event on the engine queue) server.set_plugins(plugins.clone()); // socket: list-plugins, plugin commands, plugin-attach let mut host = HostLoop::new(Some(server), Waiting::WithEngine); host.set_plugins(plugins.clone()); // pump after the server on every turn; watchdog cap on sleep time loop { host.turn(&mut engine, Source::Wait(None), Yield::Never, |_, _, _| {})?; } ``` A host without a socket attaches plugins with `Plugins::add`, and calls plugin commands the way `Plugins::list` and the `command`/`command_start` of `Embedded` and `Polled` do. - When registration fails, `PluginError` tells you why. For example, on `Err(PluginError::CommandTaken { owner, .. })` retry with a different name, and on `Err(PluginError::Api { supported, .. })` lower the version with `.api(supported)`. Handle the rest with a `_` arm, such as `Err(e) => eprintln!("{e}")`. --- <!-- https://zeropty.pubdoc.dev/reference/socket-protocol --> # Socket protocol The JSON protocol on the server socket, including moving sessions between servers. ## From other programs: the socket Send one line of JSON to the server socket (`~/.zeropty/mux.sock`; change it with `ZEROPTY_SOCK`) and it answers with one line. One connection can carry many commands, and answers come back in order. The socket has 0600 permissions, so only the same user (and root) can connect. ``` → {"argv":["capture-pane","-p","-t","=work:"],"cwd":"/"} ← {"code":0,"out":"...screen...","err":""} ``` - `cwd` is the base directory for relative paths (`new-session -c`, `load-buffer`). - If the request includes `"raw":true`, the answer is not JSON but a line `<code> <out byte count> <err byte count>` followed by the raw out and err bytes. This is for clients that have a hard time parsing JSON (the zsh function). - An `attach` connection exchanges frames after the first answer: `[kind u8][length u32 LE][bytes]`. Server to client: 0 (output), 1 (session ended). Client to server: 0 (keys), 1 (size: cols and rows as u16 LE), 2 (detach). - A connection whose first request is `export-sessions` or `import-sessions` is used to hand sessions over (internal): [socket protocol](/reference/socket-protocol#socket-protocol-export-sessions-import-sessions). ```sh # From any language: for example, with nc printf '{"argv":["list-sessions"],"cwd":"/"}\n' | nc -U ~/.zeropty/mux.sock ``` ## Moving sessions Moves sessions to another process. **Running programs keep running** (even the pid stays the same). The screen and scrollback are transferred as bytes, and the session's fds by fd passing (SCM_RIGHTS). ```sh zeropty pull-sessions -s ~/.zeropty/other.sock [name...] # another server's sessions into this one (all if no names) zeropty push-sessions -s ~/.zeropty/other.sock [name...] # this server's sessions to another server zeropty restart-server # a new server takes over every session and the socket ``` - The same commands work on an embedded engine. For example, a Node app can `pull-sessions` from the server when it starts and `push-sessions` them back when it exits, so sessions survive app restarts. - `restart-server` starts the new server from the `zeropty` binary you just ran. That makes it the way to **upgrade without losing sessions**. Commands that arrive during the few ms of the switch go to the old server. - The sending side releases only the sessions the receiver confirmed it took. If anything fails midway, the sessions stay where they were. Sessions whose name already exists on the other side are not moved. - **Limitation:** if an escape sequence was half received at the moment of the move, the rest of it is printed as text. ### Socket protocol: export-sessions, import-sessions These are **internal** requests that `pull-sessions`, `push-sessions` and `restart-server` use on the server socket. You will rarely send them yourself, and the session record format may change between versions (the first byte is the record version, and a record from another version is rejected with `session record of another version`). This section is kept as a human-readable reference. Implementation: `src/engine/handover.rs`. - If the **first request line** of a connection is `{"argv":["export-sessions", name...],"cwd":""}` or `{"argv":["import-sessions"],"cwd":""}`, the connection is used only to hand over sessions and is closed when done. Meanwhile the server does nothing else (usually a few ms). - Each side waits up to 10 seconds per step. - Each session is sent as `[record length u32 LE][record]`. The session's fds (whichever of input and output are open, 0\~2 of them) are attached to the 4-byte length with SCM_RIGHTS. A length of 0 marks the end. A record may not exceed 256MB. - Every other line is a single line of JSON. ``` export-sessions (taker → server) → {"argv":["export-sessions","a","b"],"cwd":""} all sessions if no names ← [length][record] ... [0] each session with its fds ← ["b"] names the server does not have → {"taken":["a"],"failed":[]} sessions taken; the server releases only these import-sessions (giver → server) → {"argv":["import-sessions"],"cwd":""} ← "ready" → [length][record] ... [0] ← {"taken":["a"],"failed":["duplicate session: b\n"]} the giver releases only the taken ones ``` `failed` holds the reason a session was not taken (a session with the same name already exists, a different record version, and so on). If the connection drops or the format is wrong at any step, the receiver releases the sessions it took, and the sessions stay on the original side. --- <!-- https://zeropty.pubdoc.dev/reference/tmux-compatibility --> # tmux compatibility What zeropty does and does not do compared with tmux. ## Feature comparison with tmux zeropty focuses on **keeping programs running, reading their screens and sending them keys**. Most features for humans sharing a terminal are not there. | Feature | tmux | zeropty | |---|---|---| | Create, kill and list sessions, send keys, read the screen | ✅ | ✅ same arguments | | `capture-pane` options | many | `-p -e -J -N -S -E -b -q` (others are errors) | | Buffers | `load/paste/set/show/save-buffer` and more | `load-buffer`, `paste-buffer`, `capture-pane -b` | | `pipe-pane`, `rename-session`, `resize-window`, `set-environment -g` | ✅ | ✅ (no `pipe-pane -I`) | | Format variables `#{...}` | hundreds | 25 session, window, pane, cursor, history and process variables (no conditionals or modifiers) | | Attach to watch as a human (`attach`) | ✅ | ✅ read-only (`-r`), Ctrl-b d, follows the terminal size | | Windows and panes | ✅ | ❌ one program per session | | Status line, copy mode, key bindings, config file, hooks | ✅ | ❌ | | Several clients attached to one session | ✅ | ✅ (the size follows the terminal that attached last) | | PTY | 1 per session (macOS limit 511\~999) | not used; 1000+ sessions | | Full-screen programs (vim and others) | ✅ | Launch them with `zeropty-tty <program>`. Verified with vim (editing, saving), less (paging, quitting), the python REPL (line editing, history) and claude. System programs that need privileges (setuid, entitlements), such as `top` and `ps`, do not work | | Access for programs | control mode (`-C`) | JSON socket protocol, Node client | | Search text across every session's screen | ❌ `capture-pane` per session | ✅ `grep-panes` (16\~32ms for 1000) | | Line discipline for key input (Enter, `C-c` signals, line editing, echo, `C-d`) | ✅ PTY | ✅ emulated by the engine (same keys checked against tmux). EOF from `C-d` closes the input | | Wait until text appears on screen or output stops | ❌ repeatedly check with `capture-pane` | ✅ `wait-pane` (the server checks only when output arrives; detection 2ms) | | Embed as a library | ❌ | ✅ C ABI, Node module, Rust crate | | Move running sessions to another process | ❌ | ✅ `pull-sessions`, `push-sessions` | | Keep sessions across server restarts and upgrades | ❌ | ✅ `restart-server` | | Platforms | most Unix systems | macOS and Linux (kqueue or epoll; DYLD or LD_PRELOAD shim) |