This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit

Nathan Herald (Mar 9, 2026, 6:54 PM +0100) 7e53baf9

+5770
+1
.gitignore
··· 1 + node_modules/
+214
DEVELOPMENT.md
··· 1 + # pty — Development Guide 2 + 3 + A persistent terminal session manager. Run long-lived processes, detach, reconnect later — from any machine over SSH. 4 + 5 + ## Objectives 6 + 7 + - Replace tmux/zellij for **session persistence** of long-running processes 8 + - Simple CLI: `pty run <name> <command>`, `pty attach <name>`, `pty peek <name>`, `pty restart <name>` 9 + - Reliable detach/reattach with full screen replay (colors, cursor position, scrollback) 10 + - Work seamlessly over SSH (Unix sockets, no port management) 11 + - Multi-client support (multiple people can observe or interact with a session) 12 + - Comprehensive test coverage — unit and integration tests for all features 13 + 14 + ## Non-goals 15 + 16 + - **Not a window manager.** No splits, tabs, or layouts. Use kitty for that. 17 + - **Not a shell.** It wraps a single command per session. 18 + - **Not a security boundary.** Unix socket permissions are standard file permissions. Peek mode is a convenience, not access control. 19 + 20 + ## Quick Reference 21 + 22 + ```sh 23 + npm install # install dependencies 24 + npm run typecheck # typecheck with tsc (no emit) 25 + npm test # run all tests once 26 + npm run test:watch # run tests in watch mode 27 + 28 + # Usage (during development) 29 + npx tsx src/cli.ts run <name> -- <command> [args...] 30 + npx tsx src/cli.ts run -d <name> -- <command> [args...] 31 + npx tsx src/cli.ts attach <name> 32 + npx tsx src/cli.ts peek <name> 33 + npx tsx src/cli.ts peek -f <name> 34 + npx tsx src/cli.ts list 35 + npx tsx src/cli.ts restart <name> 36 + npx tsx src/cli.ts kill <name> 37 + ``` 38 + 39 + Detach from any attached/following session with **Ctrl+\\**. Press Ctrl+\\ twice quickly to send it to the process. 40 + 41 + ### No build step 42 + 43 + This project ships TypeScript source directly — there is no compile step. All `.ts` files use `.ts` import extensions and are executed at runtime by [tsx](https://tsx.is). The `bin/pty` entry point locates tsx from `node_modules` and runs `src/cli.ts`. The `tsc` command is used only for typechecking (`noEmit: true`). 44 + 45 + ## Architecture 46 + 47 + ``` 48 + ┌─────────────────────────────────────────────┐ 49 + │ Daemon (one per session) │ 50 + │ │ 51 + │ ┌──────────┐ ┌───────────────────────┐ │ 52 + │ │ node-pty │───▶│ xterm-headless │ │ 53 + │ │ (PTY) │ │ (screen buffer) │ │ 54 + │ └──────────┘ │ + SerializeAddon │ │ 55 + │ ▲ └───────────────────────┘ │ 56 + │ │ │ │ 57 + │ │ serialize() │ 58 + │ │ ▼ │ 59 + │ ┌──────────────────────────────────────┐ │ 60 + │ │ Unix Socket Server │ │ 61 + │ │ ~/.local/state/pty/<name>.sock │ │ 62 + │ └──────────────────────────────────────┘ │ 63 + │ ▲ ▲ ▲ │ 64 + └───────┼──────────┼────────────┼─────────────┘ 65 + │ │ │ 66 + Client Client Peek 67 + (attach) (attach) (read-only) 68 + ``` 69 + 70 + Each session is a detached Node.js process running `src/server.ts` via tsx. It spawns the command in a PTY, feeds all output through xterm-headless to maintain a screen buffer, and listens on a Unix socket for client connections. 71 + 72 + ## Protocol 73 + 74 + Binary packets over Unix sockets: `[type: uint8][length: uint32BE][payload]` 75 + 76 + | Type | ID | Direction | Payload | 77 + |------|----|-----------|---------| 78 + | DATA | 0 | Both | Raw terminal bytes | 79 + | ATTACH | 1 | Client → Server | `[rows: uint16BE, cols: uint16BE]` (4 bytes) | 80 + | DETACH | 2 | Client → Server | Empty | 81 + | RESIZE | 3 | Client → Server | `[rows: uint16BE, cols: uint16BE]` (4 bytes) | 82 + | EXIT | 4 | Server → Client | `[exitCode: int32BE]` (4 bytes) | 83 + | SCREEN | 5 | Server → Client | ANSI escape sequences (string) | 84 + | PEEK | 6 | Client → Server | Empty | 85 + 86 + `PacketReader` handles streaming reassembly of partial reads. Decoders gracefully handle truncated payloads (defaults for size, -1 for exit code). Unknown message types are silently ignored by the server. 87 + 88 + ## Key Design Decisions 89 + 90 + ### No build step — ship TypeScript directly 91 + 92 + There is no `dist/` directory. Source files use `.ts` import extensions and run directly via tsx. The `tsconfig.json` has `noEmit: true` and `allowImportingTsExtensions: true` — tsc is only used for typechecking. This eliminates the compile-then-run dance entirely. tsx is a regular dependency (not devDependency) because it's needed at runtime. 93 + 94 + ### No TypeScript enums 95 + 96 + We avoid TS enums because they emit runtime code that can't be type-stripped. Instead, we use `as const` objects with a derived union type. This is compatible with all TS runtimes: Node's native type stripping, tsx, esbuild, swc. 97 + 98 + ### Peek clients don't affect terminal size 99 + 100 + The PTY can only be one size. If a peek client's terminal size were used, it could reflow the session — imagine vim at 120x40 suddenly becoming 40x20 because someone peeked from their phone. Readonly clients are excluded from size negotiation entirely. They see whatever fits; the active user's layout is never disrupted. 101 + 102 + ### Last attached client wins for size 103 + 104 + When multiple interactive (non-peek) clients are connected, the most recently attached client's terminal size is used for the PTY. This is simple and predictable. An alternative would be minimum dimensions across all clients, but that punishes the primary user when a smaller client connects. 105 + 106 + ### xterm-headless as the screen buffer 107 + 108 + The terminal emulation problem (parsing ANSI sequences, tracking cursor, colors, alternate screen, etc.) is genuinely hard. Instead of reimplementing it, we use xterm-headless — the same terminal engine as VS Code's terminal. The `SerializeAddon` produces ANSI escape sequences that reconstruct the screen state in any real terminal on reconnect. 109 + 110 + ### One daemon per session 111 + 112 + Each session is an independent Node.js process. No central daemon, no shared state. This means a crash in one session doesn't affect others, and sessions are naturally isolated. The tradeoff is slightly higher memory per session. 113 + 114 + ### Unix sockets for local IPC 115 + 116 + Fast, zero-config, and work transparently over SSH. No ports to manage, no firewall rules. Session sockets live at `~/.local/state/pty/<name>.sock` (override with `$PTY_SESSION_DIR`). 117 + 118 + ### PtyServer never calls process.exit() 119 + 120 + The `PtyServer` class is a library — it cleans up resources via `close()` but never exits the process. Only the daemon entry point (bottom of `server.ts`) wires up signal handlers and `process.exit()`. This keeps the class testable in-process with vitest. 121 + 122 + ### Double Ctrl+\ passthrough 123 + 124 + Ctrl+\ is the detach key. Since some programs (like vim or hx) may use Ctrl+\, pressing it twice quickly within 300ms sends Ctrl+\ to the process instead of detaching. This matches the UX pattern from `screen` and `tmux` where the prefix key is sent to the process by pressing it twice. 125 + 126 + Programs that enable the Kitty keyboard protocol (claude, helix, neovim, etc.) cause the terminal to encode Ctrl+\ as `\x1b[92;5u` instead of the legacy byte `0x1c`. The client normalizes both encodings before detach processing so the detach key works regardless of which keyboard protocol is active. 127 + 128 + ### Terminal sanitize on disconnect 129 + 130 + When a client detaches or a session exits, the client writes escape sequences to reset terminal modes that the PTY program may have enabled: mouse tracking (all three modes), SGR mouse, hidden cursor, and bracketed paste. This prevents "poisoned" terminal state (e.g., mouse movements producing escape characters) without clearing screen content. 131 + 132 + ### Spawn through shell 133 + 134 + The server spawns commands via `/bin/sh -c 'exec "$@"'` rather than calling `posix_spawnp` directly. This handles shell scripts, symlinks, shebangs, and other executable formats that `posix_spawnp` may reject. `exec` replaces the shell with the actual process so there is no extra process. This matches the approach used by tmux and screen. 135 + 136 + ### Session name validation 137 + 138 + Session names are restricted to `[a-zA-Z0-9._-]` to prevent path traversal and shell injection. This is validated before any filesystem operations. 139 + 140 + ### Race condition prevention 141 + 142 + Concurrent `pty run` calls with the same name are protected by an exclusive lock file (`<name>.lock`). The lock includes the PID and is automatically stolen if the holding process dies. 143 + 144 + ### Daemon spawn failure detection 145 + 146 + When `spawnDaemon` launches the daemon, it monitors the child for early exit. If the daemon crashes before the socket appears, the error is surfaced immediately instead of waiting for the 3-second timeout. 147 + 148 + ## Testing 149 + 150 + Tests use **vitest** and live in `tests/`. 151 + 152 + - `protocol.test.ts` — Unit tests for packet encoding, decoding, and streaming reassembly (partial reads, split packets, large payloads) 153 + - `integration.test.ts` — Full integration tests that spawn real PTY sessions, connect clients via sockets, and verify behavior 154 + - `screenshot.test.ts` — Screenshot-based tests that capture terminal state (ANSI and plain text) and assert on visual output from real programs (vim, nano, ls, bash). Covers control characters, terminal resize, alternate screen buffer, multiple clients, unicode, and daemon spawning. 155 + 156 + All tests run in `/tmp/` directories to avoid polluting the project folder (e.g., vim swap files). Integration and screenshot tests use real processes and real Unix sockets. Each test creates a uniquely-named session and cleans up afterward. There is a `200ms` delay in some tests to allow xterm-headless to process async writes before checking screen state — this is a known characteristic of xterm's write pipeline, not a flaky test. 157 + 158 + ### Running tests 159 + 160 + ```sh 161 + npm test # run once 162 + npm run test:watch # watch mode 163 + npx vitest run -t "peek" # run tests matching "peek" 164 + ``` 165 + 166 + ### node-pty on macOS 167 + 168 + npm sometimes extracts the `spawn-helper` binary without the execute bit. The `postinstall` script in `package.json` fixes this automatically. If you still see `posix_spawnp failed`, run manually: 169 + 170 + ```sh 171 + chmod +x node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper 172 + ``` 173 + 174 + ## File Structure 175 + 176 + ``` 177 + src/ 178 + cli.ts CLI entry point and command routing 179 + server.ts PtyServer class + daemon entry point 180 + client.ts attach() and peek() functions 181 + protocol.ts Packet types, encoding, decoding, PacketReader 182 + sessions.ts Session discovery, socket/PID file management 183 + tests/ 184 + protocol.test.ts 185 + integration.test.ts 186 + screenshot.test.ts 187 + completions/ 188 + pty.bash Bash tab completion 189 + pty.zsh Zsh tab completion 190 + bin/ 191 + pty Entry point (locates tsx, runs src/cli.ts) 192 + ``` 193 + 194 + ## Future 195 + 196 + ### WebSocket server 197 + 198 + Add a WebSocket listener alongside the Unix socket so remote clients (phones, browsers) can connect without SSH. The `PtyServer` already manages clients generically — a WebSocket client would be another entry in the clients map, same protocol. 199 + 200 + ### Web UI 201 + 202 + A lightweight browser-based terminal UI that connects via WebSocket. Could use xterm.js on the client side since the server already speaks its language. 203 + 204 + ### Native SwiftUI app 205 + 206 + An iOS/macOS app for connecting to sessions over the network (via Tailscale). The binary protocol is simple enough to implement in Swift. The app would list available sessions, connect via WebSocket, and render the terminal natively. 207 + 208 + ### Kitty kitten 209 + 210 + A Python kitten for kitty that lists sessions and opens them in new kitty windows. Would use kitty's remote control protocol to create windows and the Unix socket to attach. 211 + 212 + ### Session groups / profiles 213 + 214 + Named configurations for starting multiple related sessions at once (e.g., "start my dev environment" → web server + database + file watcher).
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Nathan Herald 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+42
README.md
··· 1 + # pty 2 + 3 + Persistent terminal sessions. Run a process, detach, reconnect later. From anywhere, locally and over SSH. 4 + 5 + Uses [@xterm/headless](https://github.com/xtermjs/xterm.js/tree/master/headless) internally. 6 + 7 + ## Install 8 + 9 + ```sh 10 + git clone https://github.com/myobie/pty.git 11 + cd pty 12 + npm install 13 + npm link 14 + ``` 15 + 16 + ## Usage 17 + 18 + ```sh 19 + pty run myserver -- node server.js # start a session and attach 20 + pty run -d myserver -- node server.js # start in the background 21 + 22 + pty list # show active sessions 23 + pty attach myserver # reconnect 24 + pty peek myserver # print current screen and exit 25 + pty peek -f myserver # follow output read-only 26 + 27 + pty restart myserver # restart an exited session 28 + pty kill myserver # terminate a session 29 + ``` 30 + 31 + Detach with `Ctrl+\`. (Press `Ctrl+\` twice to send it through to the process.) 32 + 33 + ## Tab Completion 34 + 35 + ```sh 36 + brew install bash-completion # required for bash; zsh works out of the box 37 + npm run install-completions 38 + ``` 39 + 40 + ## License 41 + 42 + MIT
+15
bin/pty
··· 1 + #!/usr/bin/env node 2 + import { spawnSync } from 'node:child_process'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { dirname, join } from 'node:path'; 5 + 6 + const __dirname = dirname(fileURLToPath(import.meta.url)); 7 + const tsx = join(__dirname, '..', 'node_modules', '.bin', 'tsx'); 8 + const cli = join(__dirname, '..', 'src', 'cli.ts'); 9 + 10 + const result = spawnSync(tsx, [cli, ...process.argv.slice(2)], { 11 + stdio: 'inherit', 12 + env: process.env, 13 + }); 14 + 15 + process.exit(result.status ?? 1);
+30
completions/pty.bash
··· 1 + # Bash completion for pty 2 + # Source this file or copy to /etc/bash_completion.d/pty 3 + 4 + _pty() { 5 + local cur prev commands 6 + COMPREPLY=() 7 + cur="${COMP_WORDS[COMP_CWORD]}" 8 + prev="${COMP_WORDS[COMP_CWORD-1]}" 9 + commands="run attach peek kill list restart help" 10 + 11 + # Complete subcommand 12 + if [[ ${COMP_CWORD} -eq 1 ]]; then 13 + COMPREPLY=($(compgen -W "${commands}" -- "${cur}")) 14 + return 15 + fi 16 + 17 + # Complete session names for commands that take them 18 + case "${COMP_WORDS[1]}" in 19 + attach|a|peek|kill|restart) 20 + local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 21 + if [[ -d "${session_dir}" ]]; then 22 + local names 23 + names=$(ls "${session_dir}"/*.json 2>/dev/null | xargs -I{} basename {} .json) 24 + COMPREPLY=($(compgen -W "${names}" -- "${cur}")) 25 + fi 26 + ;; 27 + esac 28 + } 29 + 30 + complete -F _pty pty
+62
completions/pty.zsh
··· 1 + #compdef pty 2 + # Zsh completion for pty 3 + # Place in your fpath or source directly 4 + 5 + _pty() { 6 + local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 7 + 8 + _pty_sessions() { 9 + local -a sessions 10 + if [[ -d "${session_dir}" ]]; then 11 + sessions=(${session_dir}/*.json(N:t:r)) 12 + fi 13 + _describe 'session' sessions 14 + } 15 + 16 + local -a commands 17 + commands=( 18 + 'run:Create a session and attach' 19 + 'attach:Attach to an existing session' 20 + 'peek:Print current screen or follow output' 21 + 'kill:Kill or remove a session' 22 + 'list:List active sessions' 23 + 'restart:Restart an exited session' 24 + 'help:Show usage information' 25 + ) 26 + 27 + _arguments -C \ 28 + '1:command:->command' \ 29 + '*::arg:->args' 30 + 31 + case $state in 32 + command) 33 + _describe 'command' commands 34 + ;; 35 + args) 36 + case ${words[1]} in 37 + attach|a) 38 + _arguments \ 39 + '(-r --auto-restart)'{-r,--auto-restart}'[Auto-restart if exited]' \ 40 + '1:session:_pty_sessions' 41 + ;; 42 + peek) 43 + _arguments \ 44 + '(-f --follow)'{-f,--follow}'[Follow output read-only]' \ 45 + '1:session:_pty_sessions' 46 + ;; 47 + kill|restart) 48 + _arguments '1:session:_pty_sessions' 49 + ;; 50 + run) 51 + _arguments \ 52 + '(-d --detach)'{-d,--detach}'[Create in background]' \ 53 + '(-a --attach)'{-a,--attach}'[Attach if already running]' \ 54 + '1:name:' \ 55 + '*:command:_command_names -e' 56 + ;; 57 + esac 58 + ;; 59 + esac 60 + } 61 + 62 + _pty "$@"
+1551
package-lock.json
··· 1 + { 2 + "name": "pty-manager", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "pty-manager", 9 + "version": "0.1.0", 10 + "hasInstallScript": true, 11 + "dependencies": { 12 + "@xterm/addon-serialize": "^0.14.0", 13 + "@xterm/headless": "^6.0.0", 14 + "node-pty": "^1.0.0", 15 + "tsx": "^4.19.0" 16 + }, 17 + "bin": { 18 + "pty": "bin/pty" 19 + }, 20 + "devDependencies": { 21 + "@types/node": "^25.3.5", 22 + "typescript": "^5.7.0", 23 + "vitest": "^4.0.18" 24 + } 25 + }, 26 + "node_modules/@esbuild/aix-ppc64": { 27 + "version": "0.27.3", 28 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", 29 + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", 30 + "cpu": [ 31 + "ppc64" 32 + ], 33 + "license": "MIT", 34 + "optional": true, 35 + "os": [ 36 + "aix" 37 + ], 38 + "engines": { 39 + "node": ">=18" 40 + } 41 + }, 42 + "node_modules/@esbuild/android-arm": { 43 + "version": "0.27.3", 44 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", 45 + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", 46 + "cpu": [ 47 + "arm" 48 + ], 49 + "license": "MIT", 50 + "optional": true, 51 + "os": [ 52 + "android" 53 + ], 54 + "engines": { 55 + "node": ">=18" 56 + } 57 + }, 58 + "node_modules/@esbuild/android-arm64": { 59 + "version": "0.27.3", 60 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", 61 + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", 62 + "cpu": [ 63 + "arm64" 64 + ], 65 + "license": "MIT", 66 + "optional": true, 67 + "os": [ 68 + "android" 69 + ], 70 + "engines": { 71 + "node": ">=18" 72 + } 73 + }, 74 + "node_modules/@esbuild/android-x64": { 75 + "version": "0.27.3", 76 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", 77 + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", 78 + "cpu": [ 79 + "x64" 80 + ], 81 + "license": "MIT", 82 + "optional": true, 83 + "os": [ 84 + "android" 85 + ], 86 + "engines": { 87 + "node": ">=18" 88 + } 89 + }, 90 + "node_modules/@esbuild/darwin-arm64": { 91 + "version": "0.27.3", 92 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", 93 + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", 94 + "cpu": [ 95 + "arm64" 96 + ], 97 + "license": "MIT", 98 + "optional": true, 99 + "os": [ 100 + "darwin" 101 + ], 102 + "engines": { 103 + "node": ">=18" 104 + } 105 + }, 106 + "node_modules/@esbuild/darwin-x64": { 107 + "version": "0.27.3", 108 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", 109 + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", 110 + "cpu": [ 111 + "x64" 112 + ], 113 + "license": "MIT", 114 + "optional": true, 115 + "os": [ 116 + "darwin" 117 + ], 118 + "engines": { 119 + "node": ">=18" 120 + } 121 + }, 122 + "node_modules/@esbuild/freebsd-arm64": { 123 + "version": "0.27.3", 124 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", 125 + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", 126 + "cpu": [ 127 + "arm64" 128 + ], 129 + "license": "MIT", 130 + "optional": true, 131 + "os": [ 132 + "freebsd" 133 + ], 134 + "engines": { 135 + "node": ">=18" 136 + } 137 + }, 138 + "node_modules/@esbuild/freebsd-x64": { 139 + "version": "0.27.3", 140 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", 141 + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", 142 + "cpu": [ 143 + "x64" 144 + ], 145 + "license": "MIT", 146 + "optional": true, 147 + "os": [ 148 + "freebsd" 149 + ], 150 + "engines": { 151 + "node": ">=18" 152 + } 153 + }, 154 + "node_modules/@esbuild/linux-arm": { 155 + "version": "0.27.3", 156 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", 157 + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", 158 + "cpu": [ 159 + "arm" 160 + ], 161 + "license": "MIT", 162 + "optional": true, 163 + "os": [ 164 + "linux" 165 + ], 166 + "engines": { 167 + "node": ">=18" 168 + } 169 + }, 170 + "node_modules/@esbuild/linux-arm64": { 171 + "version": "0.27.3", 172 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", 173 + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", 174 + "cpu": [ 175 + "arm64" 176 + ], 177 + "license": "MIT", 178 + "optional": true, 179 + "os": [ 180 + "linux" 181 + ], 182 + "engines": { 183 + "node": ">=18" 184 + } 185 + }, 186 + "node_modules/@esbuild/linux-ia32": { 187 + "version": "0.27.3", 188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", 189 + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", 190 + "cpu": [ 191 + "ia32" 192 + ], 193 + "license": "MIT", 194 + "optional": true, 195 + "os": [ 196 + "linux" 197 + ], 198 + "engines": { 199 + "node": ">=18" 200 + } 201 + }, 202 + "node_modules/@esbuild/linux-loong64": { 203 + "version": "0.27.3", 204 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", 205 + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", 206 + "cpu": [ 207 + "loong64" 208 + ], 209 + "license": "MIT", 210 + "optional": true, 211 + "os": [ 212 + "linux" 213 + ], 214 + "engines": { 215 + "node": ">=18" 216 + } 217 + }, 218 + "node_modules/@esbuild/linux-mips64el": { 219 + "version": "0.27.3", 220 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", 221 + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", 222 + "cpu": [ 223 + "mips64el" 224 + ], 225 + "license": "MIT", 226 + "optional": true, 227 + "os": [ 228 + "linux" 229 + ], 230 + "engines": { 231 + "node": ">=18" 232 + } 233 + }, 234 + "node_modules/@esbuild/linux-ppc64": { 235 + "version": "0.27.3", 236 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", 237 + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", 238 + "cpu": [ 239 + "ppc64" 240 + ], 241 + "license": "MIT", 242 + "optional": true, 243 + "os": [ 244 + "linux" 245 + ], 246 + "engines": { 247 + "node": ">=18" 248 + } 249 + }, 250 + "node_modules/@esbuild/linux-riscv64": { 251 + "version": "0.27.3", 252 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", 253 + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", 254 + "cpu": [ 255 + "riscv64" 256 + ], 257 + "license": "MIT", 258 + "optional": true, 259 + "os": [ 260 + "linux" 261 + ], 262 + "engines": { 263 + "node": ">=18" 264 + } 265 + }, 266 + "node_modules/@esbuild/linux-s390x": { 267 + "version": "0.27.3", 268 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", 269 + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", 270 + "cpu": [ 271 + "s390x" 272 + ], 273 + "license": "MIT", 274 + "optional": true, 275 + "os": [ 276 + "linux" 277 + ], 278 + "engines": { 279 + "node": ">=18" 280 + } 281 + }, 282 + "node_modules/@esbuild/linux-x64": { 283 + "version": "0.27.3", 284 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", 285 + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", 286 + "cpu": [ 287 + "x64" 288 + ], 289 + "license": "MIT", 290 + "optional": true, 291 + "os": [ 292 + "linux" 293 + ], 294 + "engines": { 295 + "node": ">=18" 296 + } 297 + }, 298 + "node_modules/@esbuild/netbsd-arm64": { 299 + "version": "0.27.3", 300 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", 301 + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", 302 + "cpu": [ 303 + "arm64" 304 + ], 305 + "license": "MIT", 306 + "optional": true, 307 + "os": [ 308 + "netbsd" 309 + ], 310 + "engines": { 311 + "node": ">=18" 312 + } 313 + }, 314 + "node_modules/@esbuild/netbsd-x64": { 315 + "version": "0.27.3", 316 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", 317 + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", 318 + "cpu": [ 319 + "x64" 320 + ], 321 + "license": "MIT", 322 + "optional": true, 323 + "os": [ 324 + "netbsd" 325 + ], 326 + "engines": { 327 + "node": ">=18" 328 + } 329 + }, 330 + "node_modules/@esbuild/openbsd-arm64": { 331 + "version": "0.27.3", 332 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", 333 + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", 334 + "cpu": [ 335 + "arm64" 336 + ], 337 + "license": "MIT", 338 + "optional": true, 339 + "os": [ 340 + "openbsd" 341 + ], 342 + "engines": { 343 + "node": ">=18" 344 + } 345 + }, 346 + "node_modules/@esbuild/openbsd-x64": { 347 + "version": "0.27.3", 348 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", 349 + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", 350 + "cpu": [ 351 + "x64" 352 + ], 353 + "license": "MIT", 354 + "optional": true, 355 + "os": [ 356 + "openbsd" 357 + ], 358 + "engines": { 359 + "node": ">=18" 360 + } 361 + }, 362 + "node_modules/@esbuild/openharmony-arm64": { 363 + "version": "0.27.3", 364 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", 365 + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", 366 + "cpu": [ 367 + "arm64" 368 + ], 369 + "license": "MIT", 370 + "optional": true, 371 + "os": [ 372 + "openharmony" 373 + ], 374 + "engines": { 375 + "node": ">=18" 376 + } 377 + }, 378 + "node_modules/@esbuild/sunos-x64": { 379 + "version": "0.27.3", 380 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", 381 + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", 382 + "cpu": [ 383 + "x64" 384 + ], 385 + "license": "MIT", 386 + "optional": true, 387 + "os": [ 388 + "sunos" 389 + ], 390 + "engines": { 391 + "node": ">=18" 392 + } 393 + }, 394 + "node_modules/@esbuild/win32-arm64": { 395 + "version": "0.27.3", 396 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", 397 + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", 398 + "cpu": [ 399 + "arm64" 400 + ], 401 + "license": "MIT", 402 + "optional": true, 403 + "os": [ 404 + "win32" 405 + ], 406 + "engines": { 407 + "node": ">=18" 408 + } 409 + }, 410 + "node_modules/@esbuild/win32-ia32": { 411 + "version": "0.27.3", 412 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", 413 + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", 414 + "cpu": [ 415 + "ia32" 416 + ], 417 + "license": "MIT", 418 + "optional": true, 419 + "os": [ 420 + "win32" 421 + ], 422 + "engines": { 423 + "node": ">=18" 424 + } 425 + }, 426 + "node_modules/@esbuild/win32-x64": { 427 + "version": "0.27.3", 428 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", 429 + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", 430 + "cpu": [ 431 + "x64" 432 + ], 433 + "license": "MIT", 434 + "optional": true, 435 + "os": [ 436 + "win32" 437 + ], 438 + "engines": { 439 + "node": ">=18" 440 + } 441 + }, 442 + "node_modules/@jridgewell/sourcemap-codec": { 443 + "version": "1.5.5", 444 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 445 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 446 + "dev": true, 447 + "license": "MIT" 448 + }, 449 + "node_modules/@rollup/rollup-android-arm-eabi": { 450 + "version": "4.59.0", 451 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", 452 + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", 453 + "cpu": [ 454 + "arm" 455 + ], 456 + "dev": true, 457 + "license": "MIT", 458 + "optional": true, 459 + "os": [ 460 + "android" 461 + ] 462 + }, 463 + "node_modules/@rollup/rollup-android-arm64": { 464 + "version": "4.59.0", 465 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", 466 + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", 467 + "cpu": [ 468 + "arm64" 469 + ], 470 + "dev": true, 471 + "license": "MIT", 472 + "optional": true, 473 + "os": [ 474 + "android" 475 + ] 476 + }, 477 + "node_modules/@rollup/rollup-darwin-arm64": { 478 + "version": "4.59.0", 479 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", 480 + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", 481 + "cpu": [ 482 + "arm64" 483 + ], 484 + "dev": true, 485 + "license": "MIT", 486 + "optional": true, 487 + "os": [ 488 + "darwin" 489 + ] 490 + }, 491 + "node_modules/@rollup/rollup-darwin-x64": { 492 + "version": "4.59.0", 493 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", 494 + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", 495 + "cpu": [ 496 + "x64" 497 + ], 498 + "dev": true, 499 + "license": "MIT", 500 + "optional": true, 501 + "os": [ 502 + "darwin" 503 + ] 504 + }, 505 + "node_modules/@rollup/rollup-freebsd-arm64": { 506 + "version": "4.59.0", 507 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", 508 + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", 509 + "cpu": [ 510 + "arm64" 511 + ], 512 + "dev": true, 513 + "license": "MIT", 514 + "optional": true, 515 + "os": [ 516 + "freebsd" 517 + ] 518 + }, 519 + "node_modules/@rollup/rollup-freebsd-x64": { 520 + "version": "4.59.0", 521 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", 522 + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", 523 + "cpu": [ 524 + "x64" 525 + ], 526 + "dev": true, 527 + "license": "MIT", 528 + "optional": true, 529 + "os": [ 530 + "freebsd" 531 + ] 532 + }, 533 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 534 + "version": "4.59.0", 535 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", 536 + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", 537 + "cpu": [ 538 + "arm" 539 + ], 540 + "dev": true, 541 + "license": "MIT", 542 + "optional": true, 543 + "os": [ 544 + "linux" 545 + ] 546 + }, 547 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 548 + "version": "4.59.0", 549 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", 550 + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", 551 + "cpu": [ 552 + "arm" 553 + ], 554 + "dev": true, 555 + "license": "MIT", 556 + "optional": true, 557 + "os": [ 558 + "linux" 559 + ] 560 + }, 561 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 562 + "version": "4.59.0", 563 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", 564 + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", 565 + "cpu": [ 566 + "arm64" 567 + ], 568 + "dev": true, 569 + "license": "MIT", 570 + "optional": true, 571 + "os": [ 572 + "linux" 573 + ] 574 + }, 575 + "node_modules/@rollup/rollup-linux-arm64-musl": { 576 + "version": "4.59.0", 577 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", 578 + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", 579 + "cpu": [ 580 + "arm64" 581 + ], 582 + "dev": true, 583 + "license": "MIT", 584 + "optional": true, 585 + "os": [ 586 + "linux" 587 + ] 588 + }, 589 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 590 + "version": "4.59.0", 591 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", 592 + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", 593 + "cpu": [ 594 + "loong64" 595 + ], 596 + "dev": true, 597 + "license": "MIT", 598 + "optional": true, 599 + "os": [ 600 + "linux" 601 + ] 602 + }, 603 + "node_modules/@rollup/rollup-linux-loong64-musl": { 604 + "version": "4.59.0", 605 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", 606 + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", 607 + "cpu": [ 608 + "loong64" 609 + ], 610 + "dev": true, 611 + "license": "MIT", 612 + "optional": true, 613 + "os": [ 614 + "linux" 615 + ] 616 + }, 617 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 618 + "version": "4.59.0", 619 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", 620 + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", 621 + "cpu": [ 622 + "ppc64" 623 + ], 624 + "dev": true, 625 + "license": "MIT", 626 + "optional": true, 627 + "os": [ 628 + "linux" 629 + ] 630 + }, 631 + "node_modules/@rollup/rollup-linux-ppc64-musl": { 632 + "version": "4.59.0", 633 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", 634 + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", 635 + "cpu": [ 636 + "ppc64" 637 + ], 638 + "dev": true, 639 + "license": "MIT", 640 + "optional": true, 641 + "os": [ 642 + "linux" 643 + ] 644 + }, 645 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 646 + "version": "4.59.0", 647 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", 648 + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", 649 + "cpu": [ 650 + "riscv64" 651 + ], 652 + "dev": true, 653 + "license": "MIT", 654 + "optional": true, 655 + "os": [ 656 + "linux" 657 + ] 658 + }, 659 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 660 + "version": "4.59.0", 661 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", 662 + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", 663 + "cpu": [ 664 + "riscv64" 665 + ], 666 + "dev": true, 667 + "license": "MIT", 668 + "optional": true, 669 + "os": [ 670 + "linux" 671 + ] 672 + }, 673 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 674 + "version": "4.59.0", 675 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", 676 + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", 677 + "cpu": [ 678 + "s390x" 679 + ], 680 + "dev": true, 681 + "license": "MIT", 682 + "optional": true, 683 + "os": [ 684 + "linux" 685 + ] 686 + }, 687 + "node_modules/@rollup/rollup-linux-x64-gnu": { 688 + "version": "4.59.0", 689 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", 690 + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", 691 + "cpu": [ 692 + "x64" 693 + ], 694 + "dev": true, 695 + "license": "MIT", 696 + "optional": true, 697 + "os": [ 698 + "linux" 699 + ] 700 + }, 701 + "node_modules/@rollup/rollup-linux-x64-musl": { 702 + "version": "4.59.0", 703 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", 704 + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", 705 + "cpu": [ 706 + "x64" 707 + ], 708 + "dev": true, 709 + "license": "MIT", 710 + "optional": true, 711 + "os": [ 712 + "linux" 713 + ] 714 + }, 715 + "node_modules/@rollup/rollup-openbsd-x64": { 716 + "version": "4.59.0", 717 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", 718 + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", 719 + "cpu": [ 720 + "x64" 721 + ], 722 + "dev": true, 723 + "license": "MIT", 724 + "optional": true, 725 + "os": [ 726 + "openbsd" 727 + ] 728 + }, 729 + "node_modules/@rollup/rollup-openharmony-arm64": { 730 + "version": "4.59.0", 731 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", 732 + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", 733 + "cpu": [ 734 + "arm64" 735 + ], 736 + "dev": true, 737 + "license": "MIT", 738 + "optional": true, 739 + "os": [ 740 + "openharmony" 741 + ] 742 + }, 743 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 744 + "version": "4.59.0", 745 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", 746 + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", 747 + "cpu": [ 748 + "arm64" 749 + ], 750 + "dev": true, 751 + "license": "MIT", 752 + "optional": true, 753 + "os": [ 754 + "win32" 755 + ] 756 + }, 757 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 758 + "version": "4.59.0", 759 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", 760 + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", 761 + "cpu": [ 762 + "ia32" 763 + ], 764 + "dev": true, 765 + "license": "MIT", 766 + "optional": true, 767 + "os": [ 768 + "win32" 769 + ] 770 + }, 771 + "node_modules/@rollup/rollup-win32-x64-gnu": { 772 + "version": "4.59.0", 773 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", 774 + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", 775 + "cpu": [ 776 + "x64" 777 + ], 778 + "dev": true, 779 + "license": "MIT", 780 + "optional": true, 781 + "os": [ 782 + "win32" 783 + ] 784 + }, 785 + "node_modules/@rollup/rollup-win32-x64-msvc": { 786 + "version": "4.59.0", 787 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", 788 + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", 789 + "cpu": [ 790 + "x64" 791 + ], 792 + "dev": true, 793 + "license": "MIT", 794 + "optional": true, 795 + "os": [ 796 + "win32" 797 + ] 798 + }, 799 + "node_modules/@standard-schema/spec": { 800 + "version": "1.1.0", 801 + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", 802 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", 803 + "dev": true, 804 + "license": "MIT" 805 + }, 806 + "node_modules/@types/chai": { 807 + "version": "5.2.3", 808 + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", 809 + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", 810 + "dev": true, 811 + "license": "MIT", 812 + "dependencies": { 813 + "@types/deep-eql": "*", 814 + "assertion-error": "^2.0.1" 815 + } 816 + }, 817 + "node_modules/@types/deep-eql": { 818 + "version": "4.0.2", 819 + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", 820 + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", 821 + "dev": true, 822 + "license": "MIT" 823 + }, 824 + "node_modules/@types/estree": { 825 + "version": "1.0.8", 826 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 827 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 828 + "dev": true, 829 + "license": "MIT" 830 + }, 831 + "node_modules/@types/node": { 832 + "version": "25.3.5", 833 + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", 834 + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", 835 + "dev": true, 836 + "license": "MIT", 837 + "dependencies": { 838 + "undici-types": "~7.18.0" 839 + } 840 + }, 841 + "node_modules/@vitest/expect": { 842 + "version": "4.0.18", 843 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", 844 + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", 845 + "dev": true, 846 + "license": "MIT", 847 + "dependencies": { 848 + "@standard-schema/spec": "^1.0.0", 849 + "@types/chai": "^5.2.2", 850 + "@vitest/spy": "4.0.18", 851 + "@vitest/utils": "4.0.18", 852 + "chai": "^6.2.1", 853 + "tinyrainbow": "^3.0.3" 854 + }, 855 + "funding": { 856 + "url": "https://opencollective.com/vitest" 857 + } 858 + }, 859 + "node_modules/@vitest/mocker": { 860 + "version": "4.0.18", 861 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", 862 + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", 863 + "dev": true, 864 + "license": "MIT", 865 + "dependencies": { 866 + "@vitest/spy": "4.0.18", 867 + "estree-walker": "^3.0.3", 868 + "magic-string": "^0.30.21" 869 + }, 870 + "funding": { 871 + "url": "https://opencollective.com/vitest" 872 + }, 873 + "peerDependencies": { 874 + "msw": "^2.4.9", 875 + "vite": "^6.0.0 || ^7.0.0-0" 876 + }, 877 + "peerDependenciesMeta": { 878 + "msw": { 879 + "optional": true 880 + }, 881 + "vite": { 882 + "optional": true 883 + } 884 + } 885 + }, 886 + "node_modules/@vitest/pretty-format": { 887 + "version": "4.0.18", 888 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", 889 + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", 890 + "dev": true, 891 + "license": "MIT", 892 + "dependencies": { 893 + "tinyrainbow": "^3.0.3" 894 + }, 895 + "funding": { 896 + "url": "https://opencollective.com/vitest" 897 + } 898 + }, 899 + "node_modules/@vitest/runner": { 900 + "version": "4.0.18", 901 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", 902 + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", 903 + "dev": true, 904 + "license": "MIT", 905 + "dependencies": { 906 + "@vitest/utils": "4.0.18", 907 + "pathe": "^2.0.3" 908 + }, 909 + "funding": { 910 + "url": "https://opencollective.com/vitest" 911 + } 912 + }, 913 + "node_modules/@vitest/snapshot": { 914 + "version": "4.0.18", 915 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", 916 + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", 917 + "dev": true, 918 + "license": "MIT", 919 + "dependencies": { 920 + "@vitest/pretty-format": "4.0.18", 921 + "magic-string": "^0.30.21", 922 + "pathe": "^2.0.3" 923 + }, 924 + "funding": { 925 + "url": "https://opencollective.com/vitest" 926 + } 927 + }, 928 + "node_modules/@vitest/spy": { 929 + "version": "4.0.18", 930 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", 931 + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", 932 + "dev": true, 933 + "license": "MIT", 934 + "funding": { 935 + "url": "https://opencollective.com/vitest" 936 + } 937 + }, 938 + "node_modules/@vitest/utils": { 939 + "version": "4.0.18", 940 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", 941 + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", 942 + "dev": true, 943 + "license": "MIT", 944 + "dependencies": { 945 + "@vitest/pretty-format": "4.0.18", 946 + "tinyrainbow": "^3.0.3" 947 + }, 948 + "funding": { 949 + "url": "https://opencollective.com/vitest" 950 + } 951 + }, 952 + "node_modules/@xterm/addon-serialize": { 953 + "version": "0.14.0", 954 + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", 955 + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", 956 + "license": "MIT" 957 + }, 958 + "node_modules/@xterm/headless": { 959 + "version": "6.0.0", 960 + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", 961 + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", 962 + "license": "MIT", 963 + "workspaces": [ 964 + "addons/*" 965 + ] 966 + }, 967 + "node_modules/assertion-error": { 968 + "version": "2.0.1", 969 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 970 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 971 + "dev": true, 972 + "license": "MIT", 973 + "engines": { 974 + "node": ">=12" 975 + } 976 + }, 977 + "node_modules/chai": { 978 + "version": "6.2.2", 979 + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", 980 + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", 981 + "dev": true, 982 + "license": "MIT", 983 + "engines": { 984 + "node": ">=18" 985 + } 986 + }, 987 + "node_modules/es-module-lexer": { 988 + "version": "1.7.0", 989 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", 990 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", 991 + "dev": true, 992 + "license": "MIT" 993 + }, 994 + "node_modules/esbuild": { 995 + "version": "0.27.3", 996 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", 997 + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", 998 + "hasInstallScript": true, 999 + "license": "MIT", 1000 + "bin": { 1001 + "esbuild": "bin/esbuild" 1002 + }, 1003 + "engines": { 1004 + "node": ">=18" 1005 + }, 1006 + "optionalDependencies": { 1007 + "@esbuild/aix-ppc64": "0.27.3", 1008 + "@esbuild/android-arm": "0.27.3", 1009 + "@esbuild/android-arm64": "0.27.3", 1010 + "@esbuild/android-x64": "0.27.3", 1011 + "@esbuild/darwin-arm64": "0.27.3", 1012 + "@esbuild/darwin-x64": "0.27.3", 1013 + "@esbuild/freebsd-arm64": "0.27.3", 1014 + "@esbuild/freebsd-x64": "0.27.3", 1015 + "@esbuild/linux-arm": "0.27.3", 1016 + "@esbuild/linux-arm64": "0.27.3", 1017 + "@esbuild/linux-ia32": "0.27.3", 1018 + "@esbuild/linux-loong64": "0.27.3", 1019 + "@esbuild/linux-mips64el": "0.27.3", 1020 + "@esbuild/linux-ppc64": "0.27.3", 1021 + "@esbuild/linux-riscv64": "0.27.3", 1022 + "@esbuild/linux-s390x": "0.27.3", 1023 + "@esbuild/linux-x64": "0.27.3", 1024 + "@esbuild/netbsd-arm64": "0.27.3", 1025 + "@esbuild/netbsd-x64": "0.27.3", 1026 + "@esbuild/openbsd-arm64": "0.27.3", 1027 + "@esbuild/openbsd-x64": "0.27.3", 1028 + "@esbuild/openharmony-arm64": "0.27.3", 1029 + "@esbuild/sunos-x64": "0.27.3", 1030 + "@esbuild/win32-arm64": "0.27.3", 1031 + "@esbuild/win32-ia32": "0.27.3", 1032 + "@esbuild/win32-x64": "0.27.3" 1033 + } 1034 + }, 1035 + "node_modules/estree-walker": { 1036 + "version": "3.0.3", 1037 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1038 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1039 + "dev": true, 1040 + "license": "MIT", 1041 + "dependencies": { 1042 + "@types/estree": "^1.0.0" 1043 + } 1044 + }, 1045 + "node_modules/expect-type": { 1046 + "version": "1.3.0", 1047 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", 1048 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", 1049 + "dev": true, 1050 + "license": "Apache-2.0", 1051 + "engines": { 1052 + "node": ">=12.0.0" 1053 + } 1054 + }, 1055 + "node_modules/fdir": { 1056 + "version": "6.5.0", 1057 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 1058 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 1059 + "dev": true, 1060 + "license": "MIT", 1061 + "engines": { 1062 + "node": ">=12.0.0" 1063 + }, 1064 + "peerDependencies": { 1065 + "picomatch": "^3 || ^4" 1066 + }, 1067 + "peerDependenciesMeta": { 1068 + "picomatch": { 1069 + "optional": true 1070 + } 1071 + } 1072 + }, 1073 + "node_modules/fsevents": { 1074 + "version": "2.3.3", 1075 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1076 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1077 + "hasInstallScript": true, 1078 + "license": "MIT", 1079 + "optional": true, 1080 + "os": [ 1081 + "darwin" 1082 + ], 1083 + "engines": { 1084 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1085 + } 1086 + }, 1087 + "node_modules/get-tsconfig": { 1088 + "version": "4.13.6", 1089 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", 1090 + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", 1091 + "license": "MIT", 1092 + "dependencies": { 1093 + "resolve-pkg-maps": "^1.0.0" 1094 + }, 1095 + "funding": { 1096 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 1097 + } 1098 + }, 1099 + "node_modules/magic-string": { 1100 + "version": "0.30.21", 1101 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1102 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1103 + "dev": true, 1104 + "license": "MIT", 1105 + "dependencies": { 1106 + "@jridgewell/sourcemap-codec": "^1.5.5" 1107 + } 1108 + }, 1109 + "node_modules/nanoid": { 1110 + "version": "3.3.11", 1111 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1112 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1113 + "dev": true, 1114 + "funding": [ 1115 + { 1116 + "type": "github", 1117 + "url": "https://github.com/sponsors/ai" 1118 + } 1119 + ], 1120 + "license": "MIT", 1121 + "bin": { 1122 + "nanoid": "bin/nanoid.cjs" 1123 + }, 1124 + "engines": { 1125 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1126 + } 1127 + }, 1128 + "node_modules/node-addon-api": { 1129 + "version": "7.1.1", 1130 + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", 1131 + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", 1132 + "license": "MIT" 1133 + }, 1134 + "node_modules/node-pty": { 1135 + "version": "1.1.0", 1136 + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", 1137 + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", 1138 + "hasInstallScript": true, 1139 + "license": "MIT", 1140 + "dependencies": { 1141 + "node-addon-api": "^7.1.0" 1142 + } 1143 + }, 1144 + "node_modules/obug": { 1145 + "version": "2.1.1", 1146 + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", 1147 + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", 1148 + "dev": true, 1149 + "funding": [ 1150 + "https://github.com/sponsors/sxzz", 1151 + "https://opencollective.com/debug" 1152 + ], 1153 + "license": "MIT" 1154 + }, 1155 + "node_modules/pathe": { 1156 + "version": "2.0.3", 1157 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 1158 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 1159 + "dev": true, 1160 + "license": "MIT" 1161 + }, 1162 + "node_modules/picocolors": { 1163 + "version": "1.1.1", 1164 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1165 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1166 + "dev": true, 1167 + "license": "ISC" 1168 + }, 1169 + "node_modules/picomatch": { 1170 + "version": "4.0.3", 1171 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 1172 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 1173 + "dev": true, 1174 + "license": "MIT", 1175 + "engines": { 1176 + "node": ">=12" 1177 + }, 1178 + "funding": { 1179 + "url": "https://github.com/sponsors/jonschlinkert" 1180 + } 1181 + }, 1182 + "node_modules/postcss": { 1183 + "version": "8.5.8", 1184 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", 1185 + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", 1186 + "dev": true, 1187 + "funding": [ 1188 + { 1189 + "type": "opencollective", 1190 + "url": "https://opencollective.com/postcss/" 1191 + }, 1192 + { 1193 + "type": "tidelift", 1194 + "url": "https://tidelift.com/funding/github/npm/postcss" 1195 + }, 1196 + { 1197 + "type": "github", 1198 + "url": "https://github.com/sponsors/ai" 1199 + } 1200 + ], 1201 + "license": "MIT", 1202 + "dependencies": { 1203 + "nanoid": "^3.3.11", 1204 + "picocolors": "^1.1.1", 1205 + "source-map-js": "^1.2.1" 1206 + }, 1207 + "engines": { 1208 + "node": "^10 || ^12 || >=14" 1209 + } 1210 + }, 1211 + "node_modules/resolve-pkg-maps": { 1212 + "version": "1.0.0", 1213 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 1214 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 1215 + "license": "MIT", 1216 + "funding": { 1217 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 1218 + } 1219 + }, 1220 + "node_modules/rollup": { 1221 + "version": "4.59.0", 1222 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", 1223 + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", 1224 + "dev": true, 1225 + "license": "MIT", 1226 + "dependencies": { 1227 + "@types/estree": "1.0.8" 1228 + }, 1229 + "bin": { 1230 + "rollup": "dist/bin/rollup" 1231 + }, 1232 + "engines": { 1233 + "node": ">=18.0.0", 1234 + "npm": ">=8.0.0" 1235 + }, 1236 + "optionalDependencies": { 1237 + "@rollup/rollup-android-arm-eabi": "4.59.0", 1238 + "@rollup/rollup-android-arm64": "4.59.0", 1239 + "@rollup/rollup-darwin-arm64": "4.59.0", 1240 + "@rollup/rollup-darwin-x64": "4.59.0", 1241 + "@rollup/rollup-freebsd-arm64": "4.59.0", 1242 + "@rollup/rollup-freebsd-x64": "4.59.0", 1243 + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", 1244 + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", 1245 + "@rollup/rollup-linux-arm64-gnu": "4.59.0", 1246 + "@rollup/rollup-linux-arm64-musl": "4.59.0", 1247 + "@rollup/rollup-linux-loong64-gnu": "4.59.0", 1248 + "@rollup/rollup-linux-loong64-musl": "4.59.0", 1249 + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", 1250 + "@rollup/rollup-linux-ppc64-musl": "4.59.0", 1251 + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", 1252 + "@rollup/rollup-linux-riscv64-musl": "4.59.0", 1253 + "@rollup/rollup-linux-s390x-gnu": "4.59.0", 1254 + "@rollup/rollup-linux-x64-gnu": "4.59.0", 1255 + "@rollup/rollup-linux-x64-musl": "4.59.0", 1256 + "@rollup/rollup-openbsd-x64": "4.59.0", 1257 + "@rollup/rollup-openharmony-arm64": "4.59.0", 1258 + "@rollup/rollup-win32-arm64-msvc": "4.59.0", 1259 + "@rollup/rollup-win32-ia32-msvc": "4.59.0", 1260 + "@rollup/rollup-win32-x64-gnu": "4.59.0", 1261 + "@rollup/rollup-win32-x64-msvc": "4.59.0", 1262 + "fsevents": "~2.3.2" 1263 + } 1264 + }, 1265 + "node_modules/siginfo": { 1266 + "version": "2.0.0", 1267 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 1268 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 1269 + "dev": true, 1270 + "license": "ISC" 1271 + }, 1272 + "node_modules/source-map-js": { 1273 + "version": "1.2.1", 1274 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1275 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1276 + "dev": true, 1277 + "license": "BSD-3-Clause", 1278 + "engines": { 1279 + "node": ">=0.10.0" 1280 + } 1281 + }, 1282 + "node_modules/stackback": { 1283 + "version": "0.0.2", 1284 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 1285 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 1286 + "dev": true, 1287 + "license": "MIT" 1288 + }, 1289 + "node_modules/std-env": { 1290 + "version": "3.10.0", 1291 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", 1292 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", 1293 + "dev": true, 1294 + "license": "MIT" 1295 + }, 1296 + "node_modules/tinybench": { 1297 + "version": "2.9.0", 1298 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 1299 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 1300 + "dev": true, 1301 + "license": "MIT" 1302 + }, 1303 + "node_modules/tinyexec": { 1304 + "version": "1.0.2", 1305 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", 1306 + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", 1307 + "dev": true, 1308 + "license": "MIT", 1309 + "engines": { 1310 + "node": ">=18" 1311 + } 1312 + }, 1313 + "node_modules/tinyglobby": { 1314 + "version": "0.2.15", 1315 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 1316 + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 1317 + "dev": true, 1318 + "license": "MIT", 1319 + "dependencies": { 1320 + "fdir": "^6.5.0", 1321 + "picomatch": "^4.0.3" 1322 + }, 1323 + "engines": { 1324 + "node": ">=12.0.0" 1325 + }, 1326 + "funding": { 1327 + "url": "https://github.com/sponsors/SuperchupuDev" 1328 + } 1329 + }, 1330 + "node_modules/tinyrainbow": { 1331 + "version": "3.0.3", 1332 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", 1333 + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", 1334 + "dev": true, 1335 + "license": "MIT", 1336 + "engines": { 1337 + "node": ">=14.0.0" 1338 + } 1339 + }, 1340 + "node_modules/tsx": { 1341 + "version": "4.21.0", 1342 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", 1343 + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", 1344 + "license": "MIT", 1345 + "dependencies": { 1346 + "esbuild": "~0.27.0", 1347 + "get-tsconfig": "^4.7.5" 1348 + }, 1349 + "bin": { 1350 + "tsx": "dist/cli.mjs" 1351 + }, 1352 + "engines": { 1353 + "node": ">=18.0.0" 1354 + }, 1355 + "optionalDependencies": { 1356 + "fsevents": "~2.3.3" 1357 + } 1358 + }, 1359 + "node_modules/typescript": { 1360 + "version": "5.9.3", 1361 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1362 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1363 + "dev": true, 1364 + "license": "Apache-2.0", 1365 + "bin": { 1366 + "tsc": "bin/tsc", 1367 + "tsserver": "bin/tsserver" 1368 + }, 1369 + "engines": { 1370 + "node": ">=14.17" 1371 + } 1372 + }, 1373 + "node_modules/undici-types": { 1374 + "version": "7.18.2", 1375 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", 1376 + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", 1377 + "dev": true, 1378 + "license": "MIT" 1379 + }, 1380 + "node_modules/vite": { 1381 + "version": "7.3.1", 1382 + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", 1383 + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", 1384 + "dev": true, 1385 + "license": "MIT", 1386 + "dependencies": { 1387 + "esbuild": "^0.27.0", 1388 + "fdir": "^6.5.0", 1389 + "picomatch": "^4.0.3", 1390 + "postcss": "^8.5.6", 1391 + "rollup": "^4.43.0", 1392 + "tinyglobby": "^0.2.15" 1393 + }, 1394 + "bin": { 1395 + "vite": "bin/vite.js" 1396 + }, 1397 + "engines": { 1398 + "node": "^20.19.0 || >=22.12.0" 1399 + }, 1400 + "funding": { 1401 + "url": "https://github.com/vitejs/vite?sponsor=1" 1402 + }, 1403 + "optionalDependencies": { 1404 + "fsevents": "~2.3.3" 1405 + }, 1406 + "peerDependencies": { 1407 + "@types/node": "^20.19.0 || >=22.12.0", 1408 + "jiti": ">=1.21.0", 1409 + "less": "^4.0.0", 1410 + "lightningcss": "^1.21.0", 1411 + "sass": "^1.70.0", 1412 + "sass-embedded": "^1.70.0", 1413 + "stylus": ">=0.54.8", 1414 + "sugarss": "^5.0.0", 1415 + "terser": "^5.16.0", 1416 + "tsx": "^4.8.1", 1417 + "yaml": "^2.4.2" 1418 + }, 1419 + "peerDependenciesMeta": { 1420 + "@types/node": { 1421 + "optional": true 1422 + }, 1423 + "jiti": { 1424 + "optional": true 1425 + }, 1426 + "less": { 1427 + "optional": true 1428 + }, 1429 + "lightningcss": { 1430 + "optional": true 1431 + }, 1432 + "sass": { 1433 + "optional": true 1434 + }, 1435 + "sass-embedded": { 1436 + "optional": true 1437 + }, 1438 + "stylus": { 1439 + "optional": true 1440 + }, 1441 + "sugarss": { 1442 + "optional": true 1443 + }, 1444 + "terser": { 1445 + "optional": true 1446 + }, 1447 + "tsx": { 1448 + "optional": true 1449 + }, 1450 + "yaml": { 1451 + "optional": true 1452 + } 1453 + } 1454 + }, 1455 + "node_modules/vitest": { 1456 + "version": "4.0.18", 1457 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", 1458 + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", 1459 + "dev": true, 1460 + "license": "MIT", 1461 + "dependencies": { 1462 + "@vitest/expect": "4.0.18", 1463 + "@vitest/mocker": "4.0.18", 1464 + "@vitest/pretty-format": "4.0.18", 1465 + "@vitest/runner": "4.0.18", 1466 + "@vitest/snapshot": "4.0.18", 1467 + "@vitest/spy": "4.0.18", 1468 + "@vitest/utils": "4.0.18", 1469 + "es-module-lexer": "^1.7.0", 1470 + "expect-type": "^1.2.2", 1471 + "magic-string": "^0.30.21", 1472 + "obug": "^2.1.1", 1473 + "pathe": "^2.0.3", 1474 + "picomatch": "^4.0.3", 1475 + "std-env": "^3.10.0", 1476 + "tinybench": "^2.9.0", 1477 + "tinyexec": "^1.0.2", 1478 + "tinyglobby": "^0.2.15", 1479 + "tinyrainbow": "^3.0.3", 1480 + "vite": "^6.0.0 || ^7.0.0", 1481 + "why-is-node-running": "^2.3.0" 1482 + }, 1483 + "bin": { 1484 + "vitest": "vitest.mjs" 1485 + }, 1486 + "engines": { 1487 + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" 1488 + }, 1489 + "funding": { 1490 + "url": "https://opencollective.com/vitest" 1491 + }, 1492 + "peerDependencies": { 1493 + "@edge-runtime/vm": "*", 1494 + "@opentelemetry/api": "^1.9.0", 1495 + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", 1496 + "@vitest/browser-playwright": "4.0.18", 1497 + "@vitest/browser-preview": "4.0.18", 1498 + "@vitest/browser-webdriverio": "4.0.18", 1499 + "@vitest/ui": "4.0.18", 1500 + "happy-dom": "*", 1501 + "jsdom": "*" 1502 + }, 1503 + "peerDependenciesMeta": { 1504 + "@edge-runtime/vm": { 1505 + "optional": true 1506 + }, 1507 + "@opentelemetry/api": { 1508 + "optional": true 1509 + }, 1510 + "@types/node": { 1511 + "optional": true 1512 + }, 1513 + "@vitest/browser-playwright": { 1514 + "optional": true 1515 + }, 1516 + "@vitest/browser-preview": { 1517 + "optional": true 1518 + }, 1519 + "@vitest/browser-webdriverio": { 1520 + "optional": true 1521 + }, 1522 + "@vitest/ui": { 1523 + "optional": true 1524 + }, 1525 + "happy-dom": { 1526 + "optional": true 1527 + }, 1528 + "jsdom": { 1529 + "optional": true 1530 + } 1531 + } 1532 + }, 1533 + "node_modules/why-is-node-running": { 1534 + "version": "2.3.0", 1535 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 1536 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 1537 + "dev": true, 1538 + "license": "MIT", 1539 + "dependencies": { 1540 + "siginfo": "^2.0.0", 1541 + "stackback": "0.0.2" 1542 + }, 1543 + "bin": { 1544 + "why-is-node-running": "cli.js" 1545 + }, 1546 + "engines": { 1547 + "node": ">=8" 1548 + } 1549 + } 1550 + } 1551 + }
+27
package.json
··· 1 + { 2 + "name": "pty-manager", 3 + "version": "0.1.0", 4 + "description": "Persistent terminal sessions with detach/attach support", 5 + "type": "module", 6 + "bin": { 7 + "pty": "./bin/pty" 8 + }, 9 + "scripts": { 10 + "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true", 11 + "install-completions": "sh scripts/install-completions.sh", 12 + "typecheck": "tsc", 13 + "test": "vitest run", 14 + "test:watch": "vitest" 15 + }, 16 + "dependencies": { 17 + "@xterm/addon-serialize": "^0.14.0", 18 + "@xterm/headless": "^6.0.0", 19 + "node-pty": "^1.0.0", 20 + "tsx": "^4.19.0" 21 + }, 22 + "devDependencies": { 23 + "@types/node": "^25.3.5", 24 + "typescript": "^5.7.0", 25 + "vitest": "^4.0.18" 26 + } 27 + }
+26
scripts/install-completions.sh
··· 1 + #!/bin/sh 2 + # Install shell completions for pty. 3 + # Run automatically by postinstall or manually via: npm run install-completions 4 + 5 + set -e 6 + 7 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 8 + COMPLETIONS_DIR="$SCRIPT_DIR/../completions" 9 + 10 + # Bash completions via Homebrew 11 + for dir in /opt/homebrew/etc/bash_completion.d /usr/local/etc/bash_completion.d; do 12 + if [ -d "$dir" ]; then 13 + ln -sf "$COMPLETIONS_DIR/pty.bash" "$dir/pty" 14 + echo "Bash completions installed: $dir/pty" 15 + break 16 + fi 17 + done 18 + 19 + # Zsh completions via Homebrew 20 + for dir in /opt/homebrew/share/zsh/site-functions /usr/local/share/zsh/site-functions; do 21 + if [ -d "$dir" ]; then 22 + ln -sf "$COMPLETIONS_DIR/pty.zsh" "$dir/_pty" 23 + echo "Zsh completions installed: $dir/_pty" 24 + break 25 + fi 26 + done
+554
src/cli.ts
··· 1 + import { spawn, execFileSync } from "node:child_process"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import * as readline from "node:readline/promises"; 6 + import * as tty from "node:tty"; 7 + import { fileURLToPath } from "node:url"; 8 + import { attach, peek } from "./client.ts"; 9 + import { 10 + listSessions, 11 + getSession, 12 + getSocketPath, 13 + cleanupAll, 14 + cleanupSocket, 15 + validateName, 16 + acquireLock, 17 + releaseLock, 18 + type SessionInfo, 19 + } from "./sessions.ts"; 20 + 21 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 22 + 23 + function usage(): void { 24 + console.log(`Usage: 25 + pty run <name> <command> [args...] Create a session and attach 26 + pty run -d <name> <command> [args...] Create a session in the background 27 + pty run -a <name> <command> [args...] Create or attach if already running 28 + pty attach <name> Attach to an existing session 29 + pty attach -r <name> Attach, auto-restart if exited 30 + pty peek <name> Print current screen and exit 31 + pty peek -f <name> Follow output read-only (Ctrl+\\ to stop) 32 + pty restart <name> Restart an exited session 33 + pty list List active sessions 34 + pty kill <name> Kill or remove a session 35 + 36 + Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 37 + } 38 + 39 + async function main(): Promise<void> { 40 + const args = process.argv.slice(2); 41 + 42 + if (args.length === 0) { 43 + await cmdList(); 44 + return; 45 + } 46 + 47 + const command = args[0]; 48 + 49 + switch (command) { 50 + case "run": { 51 + // Parse flags before positional args 52 + let detach = false; 53 + let attachExisting = false; 54 + let i = 1; 55 + while (i < args.length && args[i].startsWith("-") && args[i] !== "--") { 56 + if (args[i] === "-d" || args[i] === "--detach") detach = true; 57 + else if (args[i] === "-a" || args[i] === "--attach") attachExisting = true; 58 + else break; 59 + i++; 60 + } 61 + const runArgs = args.slice(i); 62 + 63 + const dashDash = runArgs.indexOf("--"); 64 + let name: string; 65 + let cmd: string; 66 + let cmdArgs: string[]; 67 + 68 + if (dashDash !== -1) { 69 + if (dashDash !== 1) { 70 + console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]"); 71 + process.exit(1); 72 + } 73 + name = runArgs[0]; 74 + cmd = runArgs[dashDash + 1]; 75 + cmdArgs = runArgs.slice(dashDash + 2); 76 + } else { 77 + name = runArgs[0]; 78 + cmd = runArgs[1]; 79 + cmdArgs = runArgs.slice(2); 80 + } 81 + 82 + if (!name || !cmd) { 83 + console.error("Usage: pty run [-d] [-a] <name> -- <command> [args...]"); 84 + process.exit(1); 85 + } 86 + try { 87 + validateName(name); 88 + } catch (e: any) { 89 + console.error(e.message); 90 + process.exit(1); 91 + } 92 + const displayCmd = cmd; 93 + cmd = resolveCommand(cmd); 94 + await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd); 95 + break; 96 + } 97 + 98 + case "attach": 99 + case "a": { 100 + const autoRestart = 101 + args[1] === "--auto-restart" || args[1] === "-r"; 102 + const attachName = autoRestart ? args[2] : args[1]; 103 + if (!attachName) { 104 + console.error("Usage: pty attach [-r|--auto-restart] <name>"); 105 + process.exit(1); 106 + } 107 + try { 108 + validateName(attachName); 109 + } catch (e: any) { 110 + console.error(e.message); 111 + process.exit(1); 112 + } 113 + await cmdAttach(attachName, autoRestart); 114 + break; 115 + } 116 + 117 + case "peek": { 118 + const follow = args[1] === "-f" || args[1] === "--follow"; 119 + const peekName = follow ? args[2] : args[1]; 120 + if (!peekName) { 121 + console.error("Usage: pty peek [-f] <name>"); 122 + process.exit(1); 123 + } 124 + try { 125 + validateName(peekName); 126 + } catch (e: any) { 127 + console.error(e.message); 128 + process.exit(1); 129 + } 130 + cmdPeek(peekName, follow); 131 + break; 132 + } 133 + 134 + case "list": 135 + case "ls": { 136 + await cmdList(); 137 + break; 138 + } 139 + 140 + case "restart": { 141 + if (args.length < 2) { 142 + console.error("Usage: pty restart <name>"); 143 + process.exit(1); 144 + } 145 + await cmdRestart(args[1]); 146 + break; 147 + } 148 + 149 + case "kill": { 150 + if (args.length < 2) { 151 + console.error("Usage: pty kill <name>"); 152 + process.exit(1); 153 + } 154 + try { 155 + validateName(args[1]); 156 + } catch (e: any) { 157 + console.error(e.message); 158 + process.exit(1); 159 + } 160 + await cmdKill(args[1]); 161 + break; 162 + } 163 + 164 + case "help": 165 + case "--help": 166 + case "-h": { 167 + usage(); 168 + break; 169 + } 170 + 171 + default: { 172 + console.error(`Unknown command: ${command}`); 173 + usage(); 174 + process.exit(1); 175 + } 176 + } 177 + } 178 + 179 + async function cmdRun( 180 + name: string, 181 + command: string, 182 + args: string[], 183 + detach = false, 184 + attachExisting = false, 185 + displayCommand: string 186 + ): Promise<void> { 187 + const session = await getSession(name); 188 + if (session?.status === "running") { 189 + if (attachExisting) { 190 + console.log(`Session "${name}" already running, attaching.`); 191 + doAttach(name); 192 + return; 193 + } 194 + console.error( 195 + `Session "${name}" is already running. Use "pty attach ${name}" to connect.` 196 + ); 197 + process.exit(1); 198 + } 199 + 200 + if (!acquireLock(name)) { 201 + console.error( 202 + `Session "${name}" is being created by another process. Try again.` 203 + ); 204 + process.exit(1); 205 + } 206 + 207 + // Clean up any dead session with the same name 208 + if (session?.status === "exited") { 209 + cleanupAll(name); 210 + } 211 + 212 + try { 213 + await spawnDaemon(name, command, args, displayCommand); 214 + } finally { 215 + releaseLock(name); 216 + } 217 + 218 + console.log(`Session "${name}" created.`); 219 + 220 + if (detach) { 221 + return; 222 + } 223 + 224 + doAttach(name); 225 + } 226 + 227 + async function cmdAttach( 228 + name: string, 229 + autoRestart = false 230 + ): Promise<void> { 231 + const session = await getSession(name); 232 + 233 + if (!session) { 234 + console.error(`Session "${name}" not found.`); 235 + process.exit(1); 236 + } 237 + 238 + if (session.status === "running") { 239 + doAttach(name); 240 + return; 241 + } 242 + 243 + // Dead session — show last lines and offer to restart 244 + await handleDeadSession(session, autoRestart); 245 + } 246 + 247 + async function handleDeadSession( 248 + session: SessionInfo, 249 + autoRestart = false 250 + ): Promise<void> { 251 + const meta = session.metadata; 252 + if (!meta) { 253 + console.error(`Session "${session.name}" exited (no metadata available).`); 254 + cleanupAll(session.name); 255 + process.exit(1); 256 + } 257 + 258 + // Show last lines 259 + if (meta.lastLines && meta.lastLines.length > 0) { 260 + console.log(""); 261 + for (const line of meta.lastLines) { 262 + console.log(` ${line}`); 263 + } 264 + console.log(""); 265 + } 266 + 267 + console.log( 268 + `Session "${session.name}" exited with code ${meta.exitCode ?? "unknown"}.` 269 + ); 270 + 271 + const cmd = [meta.displayCommand, ...meta.args].join(" "); 272 + console.log(`Command was: ${cmd}`); 273 + console.log(""); 274 + 275 + if (!autoRestart) { 276 + const answer = await ask("Restart? [Y/n] "); 277 + if (answer.toLowerCase() === "n") { 278 + process.exit(0); 279 + } 280 + } 281 + 282 + // Restart 283 + cleanupAll(session.name); 284 + await spawnDaemon(session.name, meta.command, meta.args, meta.displayCommand); 285 + console.log(`Session "${session.name}" restarted.`); 286 + doAttach(session.name); 287 + } 288 + 289 + function doAttach(name: string): void { 290 + attach({ 291 + name, 292 + onDetach: () => process.exit(0), 293 + onExit: (code) => process.exit(code), 294 + }); 295 + } 296 + 297 + function cmdPeek(name: string, follow: boolean): void { 298 + peek({ 299 + name, 300 + follow, 301 + onDetach: () => process.exit(0), 302 + onExit: (code) => process.exit(code), 303 + }); 304 + } 305 + 306 + async function cmdList(): Promise<void> { 307 + const sessions = await listSessions(); 308 + 309 + if (sessions.length === 0) { 310 + console.log("No active sessions."); 311 + return; 312 + } 313 + 314 + const running = sessions.filter((s) => s.status === "running"); 315 + const exited = sessions.filter((s) => s.status === "exited"); 316 + 317 + if (running.length > 0) { 318 + console.log("Active sessions:"); 319 + for (const session of running) { 320 + const cmd = session.metadata 321 + ? [session.metadata.displayCommand, ...session.metadata.args].join(" ") 322 + : "unknown"; 323 + const cwd = session.metadata?.cwd 324 + ? shortPath(session.metadata.cwd) 325 + : ""; 326 + console.log(` ${session.name} (pid: ${session.pid}) — ${cwd} — ${cmd}`); 327 + } 328 + } 329 + 330 + if (exited.length > 0) { 331 + if (running.length > 0) console.log(""); 332 + console.log("Exited sessions:"); 333 + for (const session of exited) { 334 + const meta = session.metadata; 335 + const code = meta?.exitCode ?? "?"; 336 + const ago = meta?.exitedAt ? timeAgo(new Date(meta.exitedAt)) : "unknown"; 337 + const cwd = meta?.cwd ? shortPath(meta.cwd) : ""; 338 + console.log(` ${session.name} (exited with code ${code}, ${ago}) — ${cwd}`); 339 + } 340 + } 341 + } 342 + 343 + async function cmdKill(name: string): Promise<void> { 344 + const session = await getSession(name); 345 + 346 + if (!session) { 347 + console.error(`Session "${name}" not found.`); 348 + process.exit(1); 349 + } 350 + 351 + if (session.status === "running" && session.pid) { 352 + try { 353 + process.kill(session.pid, "SIGTERM"); 354 + console.log(`Session "${name}" killed.`); 355 + } catch { 356 + console.error(`Failed to kill session "${name}".`); 357 + } 358 + cleanupSocket(name); 359 + } 360 + 361 + cleanupAll(name); 362 + if (session.status === "exited") { 363 + console.log(`Session "${name}" removed.`); 364 + } 365 + } 366 + 367 + async function cmdRestart(name: string): Promise<void> { 368 + try { 369 + validateName(name); 370 + } catch (e: any) { 371 + console.error(e.message); 372 + process.exit(1); 373 + } 374 + 375 + const session = await getSession(name); 376 + 377 + if (!session) { 378 + console.error(`Session "${name}" not found.`); 379 + process.exit(1); 380 + } 381 + 382 + if (session.status === "running") { 383 + console.error( 384 + `Session "${name}" is still running. Kill it first with "pty kill ${name}".` 385 + ); 386 + process.exit(1); 387 + } 388 + 389 + const meta = session.metadata; 390 + if (!meta) { 391 + console.error(`Session "${name}" has no metadata — cannot restart.`); 392 + cleanupAll(name); 393 + process.exit(1); 394 + } 395 + 396 + cleanupAll(name); 397 + await spawnDaemon(name, meta.command, meta.args, meta.displayCommand); 398 + console.log(`Session "${name}" restarted.`); 399 + doAttach(name); 400 + } 401 + 402 + async function spawnDaemon( 403 + name: string, 404 + command: string, 405 + args: string[], 406 + displayCommand: string 407 + ): Promise<void> { 408 + const stdout = process.stdout as tty.WriteStream; 409 + const rows = stdout.rows ?? 24; 410 + const cols = stdout.columns ?? 80; 411 + 412 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 413 + const serverModule = path.join(__dirname, "server.ts"); 414 + const config = JSON.stringify({ 415 + name, 416 + command, 417 + args, 418 + displayCommand, 419 + cwd: process.cwd(), 420 + rows, 421 + cols, 422 + }); 423 + 424 + const child = spawn(tsxBin, [serverModule], { 425 + detached: true, 426 + stdio: ["ignore", "ignore", "pipe"], 427 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 428 + }); 429 + 430 + // Capture stderr for better error reporting 431 + let stderrOutput = ""; 432 + child.stderr?.on("data", (data: Buffer) => { 433 + stderrOutput += data.toString(); 434 + }); 435 + 436 + // Detect early daemon crash before the socket appears 437 + let earlyExit = false; 438 + let earlyExitCode: number | null = null; 439 + child.on("exit", (code) => { 440 + earlyExit = true; 441 + earlyExitCode = code; 442 + }); 443 + 444 + (child.stderr as any)?.unref?.(); 445 + child.unref(); 446 + 447 + await waitForSocket(name, 3000, () => { 448 + if (earlyExit) { 449 + const details = stderrOutput.trim(); 450 + const msg = `Daemon process exited immediately (code ${earlyExitCode ?? "unknown"}).`; 451 + throw new Error(details ? `${msg}\n${details}` : `${msg} Is the command valid?`); 452 + } 453 + }); 454 + } 455 + 456 + function waitForSocket( 457 + name: string, 458 + timeoutMs: number, 459 + earlyCheck?: () => void 460 + ): Promise<void> { 461 + const socketPath = getSocketPath(name); 462 + const start = Date.now(); 463 + 464 + return new Promise((resolve, reject) => { 465 + function check(): void { 466 + // Check for early daemon failure 467 + try { 468 + earlyCheck?.(); 469 + } catch (e) { 470 + reject(e); 471 + return; 472 + } 473 + 474 + if (Date.now() - start > timeoutMs) { 475 + reject(new Error(`Timeout waiting for session "${name}" to start`)); 476 + return; 477 + } 478 + 479 + try { 480 + const stat = fs.statSync(socketPath); 481 + if (stat) { 482 + setTimeout(resolve, 100); 483 + return; 484 + } 485 + } catch {} 486 + 487 + setTimeout(check, 50); 488 + } 489 + check(); 490 + }); 491 + } 492 + 493 + function ask(prompt: string): Promise<string> { 494 + const rl = readline.createInterface({ 495 + input: process.stdin, 496 + output: process.stdout, 497 + }); 498 + return rl.question(prompt).then((answer) => { 499 + rl.close(); 500 + return answer; 501 + }); 502 + } 503 + 504 + function resolveCommand(cmd: string): string { 505 + // Already absolute — just verify it exists 506 + if (path.isAbsolute(cmd)) { 507 + if (!fs.existsSync(cmd)) { 508 + console.error(`Command not found: ${cmd}`); 509 + process.exit(1); 510 + } 511 + return cmd; 512 + } 513 + 514 + // Relative path (contains /) — resolve against cwd 515 + if (cmd.includes("/")) { 516 + const resolved = path.resolve(cmd); 517 + if (!fs.existsSync(resolved)) { 518 + console.error(`Command not found: ${cmd}`); 519 + process.exit(1); 520 + } 521 + return resolved; 522 + } 523 + 524 + // Bare command name — look up in PATH 525 + try { 526 + return execFileSync("which", [cmd], { encoding: "utf8" }).trim(); 527 + } catch { 528 + console.error(`Command not found: ${cmd}`); 529 + process.exit(1); 530 + } 531 + } 532 + 533 + function shortPath(p: string): string { 534 + const home = os.homedir(); 535 + if (p === home) return "~"; 536 + if (p.startsWith(home + "/")) return "~" + p.slice(home.length); 537 + return p; 538 + } 539 + 540 + function timeAgo(date: Date): string { 541 + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); 542 + if (seconds < 60) return `${seconds}s ago`; 543 + const minutes = Math.floor(seconds / 60); 544 + if (minutes < 60) return `${minutes}m ago`; 545 + const hours = Math.floor(minutes / 60); 546 + if (hours < 24) return `${hours}h ago`; 547 + const days = Math.floor(hours / 24); 548 + return `${days}d ago`; 549 + } 550 + 551 + main().catch((err) => { 552 + console.error(err.message); 553 + process.exit(1); 554 + });
+269
src/client.ts
··· 1 + import * as net from "node:net"; 2 + import * as tty from "node:tty"; 3 + import { 4 + MessageType, 5 + PacketReader, 6 + encodeAttach, 7 + encodeData, 8 + encodeDetach, 9 + encodePeek, 10 + encodeResize, 11 + decodeExit, 12 + } from "./protocol.ts"; 13 + import { getSocketPath } from "./sessions.ts"; 14 + 15 + const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding) 16 + const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol) 17 + 18 + /** Replace Kitty keyboard protocol encoding of Ctrl+\ with the legacy byte 19 + * so the rest of the detach logic can work with a single representation. */ 20 + function normalizeDetachKey(data: Buffer): Buffer { 21 + const str = data.toString(); 22 + if (!str.includes(DETACH_KEY_KITTY)) return data; 23 + return Buffer.from( 24 + str.replaceAll(DETACH_KEY_KITTY, String.fromCharCode(DETACH_KEY)) 25 + ); 26 + } 27 + 28 + // Reset terminal modes that programs may have enabled. This prevents 29 + // "poisoned" terminals after detach/peek (e.g., mouse tracking, hidden 30 + // cursor, bracketed paste). Does NOT clear screen content. 31 + const TERMINAL_SANITIZE = 32 + "\x1b[?1000l" + // disable mouse click tracking 33 + "\x1b[?1002l" + // disable mouse button-event tracking 34 + "\x1b[?1003l" + // disable mouse any-event tracking 35 + "\x1b[?1006l" + // disable SGR mouse mode 36 + "\x1b[?25h" + // show cursor 37 + "\x1b[?2004l" + // disable bracketed paste 38 + "\x1b[<u"; // pop Kitty keyboard protocol mode 39 + 40 + export interface PeekOptions { 41 + name: string; 42 + follow?: boolean; // If true, stay connected and stream (like tail -f). If false, print screen and exit. 43 + onExit?: (code: number) => void; 44 + onDetach?: () => void; 45 + } 46 + 47 + /** Read-only view of a session. Input is ignored by the server. */ 48 + export function peek(options: PeekOptions): void { 49 + const socketPath = getSocketPath(options.name); 50 + const reader = new PacketReader(); 51 + const socket = net.createConnection(socketPath); 52 + const stdout = process.stdout; 53 + const follow = options.follow ?? false; 54 + 55 + socket.on("connect", () => { 56 + socket.write(encodePeek()); 57 + 58 + if (follow) { 59 + // In follow mode, Ctrl+\ detaches 60 + const stdin = process.stdin; 61 + if (stdin.isTTY) stdin.setRawMode(true); 62 + 63 + stdin.on("data", (raw: Buffer) => { 64 + const data = normalizeDetachKey(raw); 65 + for (let i = 0; i < data.length; i++) { 66 + if (data[i] === DETACH_KEY) { 67 + if (stdin.isTTY) stdin.setRawMode(false); 68 + socket.destroy(); 69 + stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n"); 70 + options.onDetach?.(); 71 + return; 72 + } 73 + } 74 + // All other input is silently ignored (read-only) 75 + }); 76 + } 77 + }); 78 + 79 + socket.on("data", (data: Buffer) => { 80 + const packets = reader.feed(data); 81 + for (const packet of packets) { 82 + switch (packet.type) { 83 + case MessageType.SCREEN: 84 + stdout.write(packet.payload); 85 + if (!follow) { 86 + stdout.write(TERMINAL_SANITIZE + "\n"); 87 + socket.destroy(); 88 + return; 89 + } 90 + break; 91 + 92 + case MessageType.DATA: 93 + if (follow) { 94 + stdout.write(packet.payload); 95 + } 96 + break; 97 + 98 + case MessageType.EXIT: { 99 + const code = decodeExit(packet.payload); 100 + socket.destroy(); 101 + stdout.write(TERMINAL_SANITIZE); 102 + if (follow) { 103 + stdout.write(`\r\n[session exited with code ${code}]\r\n`); 104 + } 105 + options.onExit?.(code); 106 + return; 107 + } 108 + } 109 + } 110 + }); 111 + 112 + socket.on("error", (err: NodeJS.ErrnoException) => { 113 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 114 + console.error(`Session "${options.name}" not found or not running.`); 115 + } else { 116 + console.error(`Connection error: ${err.message}`); 117 + } 118 + process.exit(1); 119 + }); 120 + 121 + socket.on("close", () => { 122 + if (process.stdin.isTTY && process.stdin.isRaw) { 123 + process.stdin.setRawMode(false); 124 + } 125 + }); 126 + } 127 + 128 + export interface AttachOptions { 129 + name: string; 130 + onExit?: (code: number) => void; 131 + onDetach?: () => void; 132 + } 133 + 134 + export function attach(options: AttachOptions): void { 135 + const socketPath = getSocketPath(options.name); 136 + const reader = new PacketReader(); 137 + const socket = net.createConnection(socketPath); 138 + 139 + const stdin = process.stdin; 140 + const stdout = process.stdout; 141 + 142 + let detaching = false; 143 + let rawWasSet = false; 144 + let exitCode = 0; 145 + 146 + function enterRawMode(): void { 147 + if (stdin.isTTY && !stdin.isRaw) { 148 + stdin.setRawMode(true); 149 + rawWasSet = true; 150 + } 151 + } 152 + 153 + function exitRawMode(): void { 154 + if (rawWasSet && stdin.isTTY) { 155 + stdin.setRawMode(false); 156 + } 157 + } 158 + 159 + function cleanExit(): void { 160 + exitRawMode(); 161 + socket.destroy(); 162 + } 163 + 164 + socket.on("connect", () => { 165 + enterRawMode(); 166 + 167 + // Tell the server our terminal size 168 + const rows = (stdout as tty.WriteStream).rows ?? 24; 169 + const cols = (stdout as tty.WriteStream).columns ?? 80; 170 + socket.write(encodeAttach(rows, cols)); 171 + 172 + // Forward stdin to server 173 + // Double Ctrl+\ passthrough: press once = detach, press twice quickly = send Ctrl+\ to process 174 + let lastDetachKeyTime = 0; 175 + const DOUBLE_TAP_MS = 300; 176 + 177 + stdin.on("data", (raw: Buffer) => { 178 + const data = normalizeDetachKey(raw); 179 + 180 + // Fast path: no detach key in this chunk 181 + if (data.indexOf(DETACH_KEY) === -1) { 182 + socket.write(encodeData(data.toString())); 183 + return; 184 + } 185 + 186 + // Slow path: detach key found — process byte by byte 187 + const forward: number[] = []; 188 + 189 + for (let i = 0; i < data.length; i++) { 190 + if (data[i] === DETACH_KEY) { 191 + const now = Date.now(); 192 + if (now - lastDetachKeyTime < DOUBLE_TAP_MS) { 193 + // Double-tap: send Ctrl+\ to the process, reset timer 194 + lastDetachKeyTime = 0; 195 + forward.push(DETACH_KEY); 196 + } else { 197 + // First tap: schedule detach (will fire if no second tap) 198 + lastDetachKeyTime = now; 199 + setTimeout(() => { 200 + if (lastDetachKeyTime === now) { 201 + detaching = true; 202 + socket.write(encodeDetach()); 203 + cleanExit(); 204 + stdout.write(TERMINAL_SANITIZE + "\r\n[detached]\r\n"); 205 + options.onDetach?.(); 206 + } 207 + }, DOUBLE_TAP_MS); 208 + } 209 + } else { 210 + forward.push(data[i]); 211 + } 212 + } 213 + 214 + if (forward.length > 0) { 215 + socket.write(encodeData(Buffer.from(forward).toString())); 216 + } 217 + }); 218 + 219 + // Handle terminal resize 220 + if (stdout instanceof tty.WriteStream) { 221 + stdout.on("resize", () => { 222 + const rows = stdout.rows; 223 + const cols = stdout.columns; 224 + socket.write(encodeResize(rows, cols)); 225 + }); 226 + } 227 + }); 228 + 229 + socket.on("data", (data: Buffer) => { 230 + const packets = reader.feed(data); 231 + for (const packet of packets) { 232 + switch (packet.type) { 233 + case MessageType.DATA: 234 + stdout.write(packet.payload); 235 + break; 236 + 237 + case MessageType.SCREEN: 238 + // Clear screen and write the replayed buffer 239 + stdout.write("\x1b[2J\x1b[H"); 240 + stdout.write(packet.payload); 241 + break; 242 + 243 + case MessageType.EXIT: 244 + exitCode = decodeExit(packet.payload); 245 + cleanExit(); 246 + stdout.write(TERMINAL_SANITIZE + `\r\n[session exited with code ${exitCode}]\r\n`); 247 + options.onExit?.(exitCode); 248 + return; 249 + } 250 + } 251 + }); 252 + 253 + socket.on("error", (err: NodeJS.ErrnoException) => { 254 + cleanExit(); 255 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 256 + console.error(`Session "${options.name}" not found or not running.`); 257 + } else { 258 + console.error(`Connection error: ${err.message}`); 259 + } 260 + process.exit(1); 261 + }); 262 + 263 + socket.on("close", () => { 264 + if (!detaching) { 265 + cleanExit(); 266 + process.exit(exitCode); 267 + } 268 + }); 269 + }
+106
src/protocol.ts
··· 1 + import { Buffer } from "node:buffer"; 2 + 3 + export const MessageType = { 4 + DATA: 0, // Terminal data (bidirectional) 5 + ATTACH: 1, // Client → Server: attaching with terminal size 6 + DETACH: 2, // Client → Server: detaching 7 + RESIZE: 3, // Client → Server: terminal resized 8 + EXIT: 4, // Server → Client: process exited 9 + SCREEN: 5, // Server → Client: screen buffer replay on attach 10 + PEEK: 6, // Client → Server: read-only attach (no input, no resize) 11 + } as const; 12 + 13 + export type MessageType = (typeof MessageType)[keyof typeof MessageType]; 14 + 15 + export interface Packet { 16 + type: MessageType; 17 + payload: Buffer; 18 + } 19 + 20 + // Packet wire format: [type: uint8][length: uint32BE][payload: N bytes] 21 + const HEADER_SIZE = 5; 22 + 23 + export function encodePacket(type: MessageType, payload: Buffer): Buffer { 24 + const header = Buffer.alloc(HEADER_SIZE); 25 + header.writeUInt8(type, 0); 26 + header.writeUInt32BE(payload.length, 1); 27 + return Buffer.concat([header, payload]); 28 + } 29 + 30 + export function encodeData(data: string): Buffer { 31 + return encodePacket(MessageType.DATA, Buffer.from(data)); 32 + } 33 + 34 + export function encodeAttach(rows: number, cols: number): Buffer { 35 + const payload = Buffer.alloc(4); 36 + payload.writeUInt16BE(rows, 0); 37 + payload.writeUInt16BE(cols, 2); 38 + return encodePacket(MessageType.ATTACH, payload); 39 + } 40 + 41 + export function encodeDetach(): Buffer { 42 + return encodePacket(MessageType.DETACH, Buffer.alloc(0)); 43 + } 44 + 45 + export function encodeResize(rows: number, cols: number): Buffer { 46 + const payload = Buffer.alloc(4); 47 + payload.writeUInt16BE(rows, 0); 48 + payload.writeUInt16BE(cols, 2); 49 + return encodePacket(MessageType.RESIZE, payload); 50 + } 51 + 52 + export function encodeExit(code: number): Buffer { 53 + const payload = Buffer.alloc(4); 54 + payload.writeInt32BE(code, 0); 55 + return encodePacket(MessageType.EXIT, payload); 56 + } 57 + 58 + export function encodePeek(): Buffer { 59 + return encodePacket(MessageType.PEEK, Buffer.alloc(0)); 60 + } 61 + 62 + export function encodeScreen(data: string): Buffer { 63 + return encodePacket(MessageType.SCREEN, Buffer.from(data)); 64 + } 65 + 66 + export function decodeSize(payload: Buffer): { rows: number; cols: number } { 67 + if (payload.length < 4) { 68 + return { rows: 24, cols: 80 }; 69 + } 70 + return { 71 + rows: payload.readUInt16BE(0), 72 + cols: payload.readUInt16BE(2), 73 + }; 74 + } 75 + 76 + export function decodeExit(payload: Buffer): number { 77 + if (payload.length < 4) { 78 + return -1; 79 + } 80 + return payload.readInt32BE(0); 81 + } 82 + 83 + /** Streaming packet parser that handles partial reads on a stream socket. */ 84 + export class PacketReader { 85 + private buffer = Buffer.alloc(0); 86 + 87 + feed(data: Buffer): Packet[] { 88 + this.buffer = Buffer.concat([this.buffer, data]); 89 + const packets: Packet[] = []; 90 + 91 + while (this.buffer.length >= HEADER_SIZE) { 92 + const type = this.buffer.readUInt8(0) as MessageType; 93 + const length = this.buffer.readUInt32BE(1); 94 + 95 + if (this.buffer.length < HEADER_SIZE + length) break; 96 + 97 + const payload = Buffer.from( 98 + this.buffer.subarray(HEADER_SIZE, HEADER_SIZE + length) 99 + ); 100 + packets.push({ type, payload }); 101 + this.buffer = this.buffer.subarray(HEADER_SIZE + length); 102 + } 103 + 104 + return packets; 105 + } 106 + }
+328
src/server.ts
··· 1 + import * as net from "node:net"; 2 + import * as fs from "node:fs"; 3 + import * as pty from "node-pty"; 4 + // @xterm packages are CJS-only. Named imports fail under Node's native ESM 5 + // loader (Node v24+), so we use default imports + separate type imports. 6 + import type { Terminal } from "@xterm/headless"; 7 + import type { SerializeAddon } from "@xterm/addon-serialize"; 8 + import xterm from "@xterm/headless"; 9 + import xtermSerialize from "@xterm/addon-serialize"; 10 + import { 11 + MessageType, 12 + PacketReader, 13 + encodeData, 14 + encodeExit, 15 + encodeScreen, 16 + decodeSize, 17 + } from "./protocol.ts"; 18 + import { 19 + getSocketPath, 20 + getPidPath, 21 + ensureSessionDir, 22 + cleanup, 23 + writeMetadata, 24 + readMetadata, 25 + type SessionMetadata, 26 + } from "./sessions.ts"; 27 + 28 + interface Client { 29 + socket: net.Socket; 30 + reader: PacketReader; 31 + rows: number; 32 + cols: number; 33 + readonly: boolean; 34 + attachSeq: number; 35 + } 36 + 37 + export interface ServerOptions { 38 + name: string; 39 + command: string; 40 + args: string[]; 41 + displayCommand: string; 42 + cwd: string; 43 + rows: number; 44 + cols: number; 45 + onExit?: (code: number) => void; 46 + } 47 + 48 + const LAST_LINES_COUNT = 20; 49 + 50 + export class PtyServer { 51 + private terminal: Terminal; 52 + private serialize: SerializeAddon; 53 + private ptyProcess: pty.IPty; 54 + private socketServer: net.Server; 55 + private clients = new Map<net.Socket, Client>(); 56 + private exited = false; 57 + private exitCode = 0; 58 + private name: string; 59 + private options: ServerOptions; 60 + private attachCounter = 0; 61 + readonly ready: Promise<void>; 62 + 63 + constructor(options: ServerOptions) { 64 + this.name = options.name; 65 + this.options = options; 66 + 67 + // Set up xterm-headless for screen buffer tracking 68 + this.terminal = new xterm.Terminal({ 69 + rows: options.rows, 70 + cols: options.cols, 71 + scrollback: 1000, 72 + allowProposedApi: true, 73 + }); 74 + this.serialize = new xtermSerialize.SerializeAddon(); 75 + this.terminal.loadAddon(this.serialize); 76 + 77 + // Spawn the child process in a PTY via a shell, so that shell scripts, 78 + // symlinks, and shebangs all work reliably (like tmux/screen do). 79 + // `exec "$@"` replaces the shell with the actual process. 80 + const childEnv = { ...process.env }; 81 + delete childEnv.PTY_SERVER_CONFIG; 82 + try { 83 + this.ptyProcess = pty.spawn( 84 + "/bin/sh", 85 + ["-c", 'exec "$@"', "sh", options.command, ...options.args], 86 + { 87 + name: "xterm-256color", 88 + cols: options.cols, 89 + rows: options.rows, 90 + cwd: options.cwd, 91 + env: childEnv as Record<string, string>, 92 + } 93 + ); 94 + } catch (err: any) { 95 + const msg = err?.message ?? String(err); 96 + if (msg.includes("posix_spawnp") || msg.includes("spawn")) { 97 + throw new Error( 98 + `Failed to spawn "${options.command}": ${msg}\nIs the command installed and executable?` 99 + ); 100 + } 101 + throw err; 102 + } 103 + 104 + // Feed PTY output into xterm-headless and broadcast to clients 105 + this.ptyProcess.onData((data: string) => { 106 + this.terminal.write(data); 107 + this.broadcast(encodeData(data)); 108 + }); 109 + 110 + this.ptyProcess.onExit(({ exitCode }) => { 111 + this.exited = true; 112 + this.exitCode = exitCode; 113 + this.broadcast(encodeExit(exitCode)); 114 + this.saveExitMetadata(exitCode); 115 + options.onExit?.(exitCode); 116 + }); 117 + 118 + // Create Unix socket server 119 + ensureSessionDir(); 120 + const socketPath = getSocketPath(this.name); 121 + 122 + // Remove stale socket if it exists 123 + try { 124 + fs.unlinkSync(socketPath); 125 + } catch {} 126 + 127 + this.socketServer = net.createServer((socket) => 128 + this.handleClient(socket) 129 + ); 130 + this.ready = new Promise((resolve) => { 131 + this.socketServer.listen(socketPath, () => { 132 + fs.writeFileSync(getPidPath(this.name), process.pid.toString()); 133 + writeMetadata(this.name, { 134 + command: options.command, 135 + args: options.args, 136 + displayCommand: options.displayCommand, 137 + cwd: options.cwd, 138 + createdAt: new Date().toISOString(), 139 + }); 140 + resolve(); 141 + }); 142 + }); 143 + 144 + this.socketServer.on("error", (err) => { 145 + console.error(`Socket server error: ${err.message}`); 146 + }); 147 + } 148 + 149 + private handleClient(socket: net.Socket): void { 150 + const client: Client = { 151 + socket, 152 + reader: new PacketReader(), 153 + rows: this.terminal.rows, 154 + cols: this.terminal.cols, 155 + readonly: false, 156 + attachSeq: 0, 157 + }; 158 + this.clients.set(socket, client); 159 + 160 + socket.on("data", (data: Buffer) => { 161 + const packets = client.reader.feed(data); 162 + for (const packet of packets) { 163 + switch (packet.type) { 164 + case MessageType.ATTACH: { 165 + if (packet.payload.length < 4) break; 166 + const size = decodeSize(packet.payload); 167 + client.rows = size.rows; 168 + client.cols = size.cols; 169 + client.attachSeq = ++this.attachCounter; 170 + this.negotiateSize(); 171 + 172 + // Send current screen state 173 + const screen = this.serialize.serialize(); 174 + socket.write(encodeScreen(screen)); 175 + 176 + // If already exited, tell them immediately 177 + if (this.exited) { 178 + socket.write(encodeExit(this.exitCode)); 179 + } 180 + break; 181 + } 182 + 183 + case MessageType.PEEK: { 184 + client.readonly = true; 185 + 186 + // Send current screen state (same as ATTACH) 187 + const peekScreen = this.serialize.serialize(); 188 + socket.write(encodeScreen(peekScreen)); 189 + 190 + if (this.exited) { 191 + socket.write(encodeExit(this.exitCode)); 192 + } 193 + break; 194 + } 195 + 196 + case MessageType.DATA: { 197 + if (!this.exited && !client.readonly) { 198 + this.ptyProcess.write(packet.payload.toString()); 199 + } 200 + break; 201 + } 202 + 203 + case MessageType.RESIZE: { 204 + if (!client.readonly && packet.payload.length >= 4) { 205 + const size = decodeSize(packet.payload); 206 + client.rows = size.rows; 207 + client.cols = size.cols; 208 + client.attachSeq = ++this.attachCounter; 209 + this.negotiateSize(); 210 + } 211 + break; 212 + } 213 + 214 + case MessageType.DETACH: { 215 + socket.end(); 216 + break; 217 + } 218 + } 219 + } 220 + }); 221 + 222 + socket.on("close", () => { 223 + this.clients.delete(socket); 224 + }); 225 + 226 + socket.on("error", () => { 227 + this.clients.delete(socket); 228 + }); 229 + } 230 + 231 + private negotiateSize(): void { 232 + // Use the most recently attached/resized non-readonly client's size 233 + let lastClient: Client | null = null; 234 + for (const client of this.clients.values()) { 235 + if (!client.readonly && client.attachSeq > 0) { 236 + if (!lastClient || client.attachSeq > lastClient.attachSeq) { 237 + lastClient = client; 238 + } 239 + } 240 + } 241 + 242 + if (lastClient) { 243 + const { rows, cols } = lastClient; 244 + if (rows !== this.terminal.rows || cols !== this.terminal.cols) { 245 + this.ptyProcess.resize(cols, rows); 246 + this.terminal.resize(cols, rows); 247 + } 248 + } 249 + } 250 + 251 + private broadcast(data: Buffer): void { 252 + for (const client of this.clients.values()) { 253 + client.socket.write(data); 254 + } 255 + } 256 + 257 + private getLastLines(): string[] { 258 + const buffer = this.terminal.buffer.active; 259 + const lines: string[] = []; 260 + for (let i = 0; i < buffer.length; i++) { 261 + const line = buffer.getLine(i); 262 + if (line) { 263 + lines.push(line.translateToString(true)); 264 + } 265 + } 266 + // Trim trailing empty lines, then take last N 267 + while (lines.length > 0 && lines[lines.length - 1] === "") { 268 + lines.pop(); 269 + } 270 + return lines.slice(-LAST_LINES_COUNT); 271 + } 272 + 273 + private saveExitMetadata(exitCode: number): void { 274 + const existing = readMetadata(this.name); 275 + writeMetadata(this.name, { 276 + command: this.options.command, 277 + args: this.options.args, 278 + displayCommand: this.options.displayCommand, 279 + cwd: this.options.cwd, 280 + createdAt: existing?.createdAt ?? new Date().toISOString(), 281 + exitCode, 282 + exitedAt: new Date().toISOString(), 283 + lastLines: this.getLastLines(), 284 + }); 285 + } 286 + 287 + /** Clean up resources. Does not call process.exit(). */ 288 + close(): Promise<void> { 289 + return new Promise((resolve) => { 290 + for (const client of this.clients.values()) { 291 + client.socket.destroy(); 292 + } 293 + this.socketServer.close(() => { 294 + cleanup(this.name); 295 + try { 296 + this.ptyProcess.kill(); 297 + } catch {} 298 + resolve(); 299 + }); 300 + }); 301 + } 302 + } 303 + 304 + /** Entry point when this file is run as the daemon process. */ 305 + if (process.argv[1]?.endsWith("/server.ts")) { 306 + const config = JSON.parse(process.env.PTY_SERVER_CONFIG ?? "{}"); 307 + if (!config.name || !config.command) { 308 + console.error("PTY_SERVER_CONFIG env var required"); 309 + process.exit(1); 310 + } 311 + 312 + const server = new PtyServer({ 313 + name: config.name, 314 + command: config.command, 315 + args: config.args ?? [], 316 + displayCommand: config.displayCommand, 317 + cwd: config.cwd ?? process.cwd(), 318 + rows: config.rows ?? 24, 319 + cols: config.cols ?? 80, 320 + onExit: (code) => { 321 + // Give clients a moment to receive the exit message, then shut down 322 + setTimeout(() => server.close().then(() => process.exit(code)), 500); 323 + }, 324 + }); 325 + 326 + process.on("SIGTERM", () => server.close().then(() => process.exit(0))); 327 + process.on("SIGINT", () => server.close().then(() => process.exit(0))); 328 + }
+269
src/sessions.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 3 + import * as os from "node:os"; 4 + import * as net from "node:net"; 5 + 6 + const SESSION_DIR = 7 + process.env.PTY_SESSION_DIR ?? 8 + path.join(os.homedir(), ".local", "state", "pty"); 9 + 10 + const DEAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 11 + 12 + const VALID_NAME_RE = /^[a-zA-Z0-9._-]+$/; 13 + 14 + export function validateName(name: string): void { 15 + if (!name || name.length === 0) { 16 + throw new Error("Session name cannot be empty."); 17 + } 18 + if (name.length > 255) { 19 + throw new Error("Session name too long (max 255 characters)."); 20 + } 21 + if (!VALID_NAME_RE.test(name)) { 22 + throw new Error( 23 + `Invalid session name "${name}". Names may only contain letters, numbers, dots, hyphens, and underscores.` 24 + ); 25 + } 26 + } 27 + 28 + export function getSessionDir(): string { 29 + return SESSION_DIR; 30 + } 31 + 32 + export function ensureSessionDir(): void { 33 + fs.mkdirSync(SESSION_DIR, { recursive: true }); 34 + } 35 + 36 + export function getSocketPath(name: string): string { 37 + return path.join(SESSION_DIR, `${name}.sock`); 38 + } 39 + 40 + export function getPidPath(name: string): string { 41 + return path.join(SESSION_DIR, `${name}.pid`); 42 + } 43 + 44 + export function getMetadataPath(name: string): string { 45 + return path.join(SESSION_DIR, `${name}.json`); 46 + } 47 + 48 + export interface SessionMetadata { 49 + command: string; 50 + args: string[]; 51 + displayCommand: string; // original command as the user typed it 52 + cwd: string; 53 + createdAt: string; 54 + exitCode?: number; 55 + exitedAt?: string; 56 + lastLines?: string[]; 57 + } 58 + 59 + export interface SessionInfo { 60 + name: string; 61 + socketPath: string; 62 + pid: number | null; 63 + status: "running" | "exited"; 64 + metadata: SessionMetadata | null; 65 + } 66 + 67 + export function writeMetadata(name: string, metadata: SessionMetadata): void { 68 + ensureSessionDir(); 69 + fs.writeFileSync(getMetadataPath(name), JSON.stringify(metadata, null, 2)); 70 + } 71 + 72 + export function readMetadata(name: string): SessionMetadata | null { 73 + try { 74 + const content = fs.readFileSync(getMetadataPath(name), "utf-8"); 75 + return JSON.parse(content); 76 + } catch { 77 + return null; 78 + } 79 + } 80 + 81 + export async function listSessions(): Promise<SessionInfo[]> { 82 + ensureSessionDir(); 83 + 84 + let entries: string[]; 85 + try { 86 + entries = fs.readdirSync(SESSION_DIR); 87 + } catch { 88 + return []; 89 + } 90 + 91 + const sessions: SessionInfo[] = []; 92 + const seen = new Set<string>(); 93 + 94 + // Find running sessions (have .sock files) 95 + const sockFiles = entries.filter((e) => e.endsWith(".sock")); 96 + for (const sockFile of sockFiles) { 97 + const name = sockFile.replace(/\.sock$/, ""); 98 + seen.add(name); 99 + const socketPath = getSocketPath(name); 100 + const pid = readPid(name); 101 + const alive = 102 + pid !== null && 103 + isProcessAlive(pid) && 104 + (await isSocketReachable(socketPath)); 105 + 106 + if (alive) { 107 + sessions.push({ 108 + name, 109 + socketPath, 110 + pid, 111 + status: "running", 112 + metadata: readMetadata(name), 113 + }); 114 + } else { 115 + // Process died — clean up socket/pid but keep metadata 116 + cleanupSocket(name); 117 + } 118 + } 119 + 120 + // Find dead sessions (have .json but no running process) 121 + const jsonFiles = entries.filter((e) => e.endsWith(".json")); 122 + for (const jsonFile of jsonFiles) { 123 + const name = jsonFile.replace(/\.json$/, ""); 124 + if (seen.has(name)) continue; // already handled above 125 + 126 + const metadata = readMetadata(name); 127 + if (!metadata) { 128 + cleanupAll(name); 129 + continue; 130 + } 131 + 132 + // Auto-clean dead sessions older than 24h 133 + if (metadata.exitedAt) { 134 + const exitedAt = new Date(metadata.exitedAt).getTime(); 135 + if (Date.now() - exitedAt > DEAD_SESSION_TTL_MS) { 136 + cleanupAll(name); 137 + continue; 138 + } 139 + } 140 + 141 + sessions.push({ 142 + name, 143 + socketPath: getSocketPath(name), 144 + pid: null, 145 + status: "exited", 146 + metadata, 147 + }); 148 + } 149 + 150 + return sessions; 151 + } 152 + 153 + export async function getSession(name: string): Promise<SessionInfo | null> { 154 + const sessions = await listSessions(); 155 + return sessions.find((s) => s.name === name) ?? null; 156 + } 157 + 158 + function readPid(name: string): number | null { 159 + try { 160 + const content = fs.readFileSync(getPidPath(name), "utf-8").trim(); 161 + const pid = parseInt(content, 10); 162 + return isNaN(pid) ? null : pid; 163 + } catch { 164 + return null; 165 + } 166 + } 167 + 168 + function isProcessAlive(pid: number): boolean { 169 + try { 170 + process.kill(pid, 0); 171 + return true; 172 + } catch { 173 + return false; 174 + } 175 + } 176 + 177 + function isSocketReachable(socketPath: string): Promise<boolean> { 178 + return new Promise((resolve) => { 179 + const socket = net.createConnection(socketPath); 180 + const timer = setTimeout(() => { 181 + socket.destroy(); 182 + resolve(false); 183 + }, 500); 184 + socket.on("connect", () => { 185 + clearTimeout(timer); 186 + socket.destroy(); 187 + resolve(true); 188 + }); 189 + socket.on("error", () => { 190 + clearTimeout(timer); 191 + resolve(false); 192 + }); 193 + }); 194 + } 195 + 196 + /** Remove socket and pid files (but keep metadata). */ 197 + export function cleanupSocket(name: string): void { 198 + try { 199 + fs.unlinkSync(getSocketPath(name)); 200 + } catch {} 201 + try { 202 + fs.unlinkSync(getPidPath(name)); 203 + } catch {} 204 + } 205 + 206 + /** Remove everything including metadata. */ 207 + export function cleanupAll(name: string): void { 208 + cleanupSocket(name); 209 + try { 210 + fs.unlinkSync(getMetadataPath(name)); 211 + } catch {} 212 + releaseLock(name); 213 + } 214 + 215 + function getLockPath(name: string): string { 216 + return path.join(SESSION_DIR, `${name}.lock`); 217 + } 218 + 219 + /** 220 + * Acquire an exclusive lock for a session name. Prevents concurrent 221 + * `pty run` calls from racing to create the same session. 222 + * Returns true if acquired, false if another process holds it. 223 + */ 224 + export function acquireLock(name: string): boolean { 225 + ensureSessionDir(); 226 + const lockPath = getLockPath(name); 227 + try { 228 + fs.writeFileSync(lockPath, process.pid.toString(), { flag: "wx" }); 229 + return true; 230 + } catch (e: any) { 231 + if (e.code === "EEXIST") { 232 + // Lock file exists — check if the holding process is still alive 233 + let shouldSteal = false; 234 + try { 235 + const pid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10); 236 + if (isNaN(pid)) { 237 + // Garbage content — treat as stale 238 + shouldSteal = true; 239 + } else { 240 + process.kill(pid, 0); // throws if process is dead 241 + return false; // process is alive, lock is valid 242 + } 243 + } catch { 244 + // Couldn't read, or holding process is dead 245 + shouldSteal = true; 246 + } 247 + 248 + if (shouldSteal) { 249 + try { 250 + fs.unlinkSync(lockPath); 251 + fs.writeFileSync(lockPath, process.pid.toString(), { flag: "wx" }); 252 + return true; 253 + } catch { 254 + return false; 255 + } 256 + } 257 + } 258 + return false; 259 + } 260 + } 261 + 262 + export function releaseLock(name: string): void { 263 + try { 264 + fs.unlinkSync(getLockPath(name)); 265 + } catch {} 266 + } 267 + 268 + // Keep backward compat for server.ts close() 269 + export { cleanupSocket as cleanup };
+633
tests/integration.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as net from "node:net"; 3 + import * as fs from "node:fs"; 4 + import * as os from "node:os"; 5 + import * as path from "node:path"; 6 + import { PtyServer, type ServerOptions } from "../src/server.ts"; 7 + import { 8 + MessageType, 9 + PacketReader, 10 + Packet, 11 + encodeAttach, 12 + encodeData, 13 + encodeDetach, 14 + encodePeek, 15 + encodeResize, 16 + decodeExit, 17 + } from "../src/protocol.ts"; 18 + import { 19 + getSocketPath, 20 + getMetadataPath, 21 + cleanupAll, 22 + readMetadata, 23 + validateName, 24 + acquireLock, 25 + releaseLock, 26 + } from "../src/sessions.ts"; 27 + 28 + // All tests run in a tmp directory to avoid polluting the project 29 + const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-int-")); 30 + afterAll(() => { 31 + fs.rmSync(testCwd, { recursive: true, force: true }); 32 + }); 33 + 34 + let servers: PtyServer[] = []; 35 + let sessionNames: string[] = []; 36 + 37 + function uniqueName(): string { 38 + const name = `test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 39 + sessionNames.push(name); 40 + return name; 41 + } 42 + 43 + async function startServer( 44 + name: string, 45 + command: string, 46 + args: string[] = [], 47 + opts: Partial<ServerOptions> = {} 48 + ): Promise<PtyServer> { 49 + const server = new PtyServer({ 50 + name, 51 + command, 52 + args, 53 + displayCommand: command, 54 + cwd: testCwd, 55 + rows: 24, 56 + cols: 80, 57 + ...opts, 58 + }); 59 + servers.push(server); 60 + await server.ready; 61 + return server; 62 + } 63 + 64 + function connect(name: string): Promise<net.Socket> { 65 + return new Promise((resolve, reject) => { 66 + const socket = net.createConnection(getSocketPath(name)); 67 + socket.on("connect", () => resolve(socket)); 68 + socket.on("error", reject); 69 + }); 70 + } 71 + 72 + /** Collect packets from a socket until we have at least `count`, or timeout. */ 73 + function collectPackets( 74 + socket: net.Socket, 75 + reader: PacketReader, 76 + count: number, 77 + timeoutMs = 5000 78 + ): Promise<Packet[]> { 79 + return new Promise((resolve) => { 80 + const packets: Packet[] = []; 81 + const timer = setTimeout(() => resolve(packets), timeoutMs); 82 + 83 + function onData(data: Buffer) { 84 + packets.push(...reader.feed(data)); 85 + if (packets.length >= count) { 86 + clearTimeout(timer); 87 + socket.off("data", onData); 88 + resolve(packets); 89 + } 90 + } 91 + 92 + socket.on("data", onData); 93 + }); 94 + } 95 + 96 + /** Wait for a specific message type. */ 97 + function waitForType( 98 + socket: net.Socket, 99 + reader: PacketReader, 100 + type: MessageType, 101 + timeoutMs = 5000 102 + ): Promise<Packet> { 103 + return new Promise((resolve, reject) => { 104 + const timer = setTimeout( 105 + () => reject(new Error(`Timed out waiting for message type ${type}`)), 106 + timeoutMs 107 + ); 108 + 109 + function onData(data: Buffer) { 110 + const packets = reader.feed(data); 111 + for (const packet of packets) { 112 + if (packet.type === type) { 113 + clearTimeout(timer); 114 + socket.off("data", onData); 115 + resolve(packet); 116 + return; 117 + } 118 + } 119 + } 120 + 121 + socket.on("data", onData); 122 + }); 123 + } 124 + 125 + afterEach(async () => { 126 + for (const server of servers) { 127 + await server.close(); 128 + } 129 + servers = []; 130 + for (const name of sessionNames) { 131 + cleanupAll(name); 132 + } 133 + sessionNames = []; 134 + }); 135 + 136 + describe("integration", () => { 137 + it("starts a session and receives screen on attach", async () => { 138 + const name = uniqueName(); 139 + await startServer(name, "echo", ["hello"]); 140 + 141 + const socket = connect(name); 142 + const client = await socket; 143 + const reader = new PacketReader(); 144 + 145 + // Send ATTACH 146 + client.write(encodeAttach(24, 80)); 147 + 148 + // Should get a SCREEN packet back 149 + const screenPacket = await waitForType(client, reader, MessageType.SCREEN); 150 + expect(screenPacket.type).toBe(MessageType.SCREEN); 151 + 152 + client.destroy(); 153 + }); 154 + 155 + it("receives process output via screen replay", async () => { 156 + const name = uniqueName(); 157 + // Use a command that prints and stays alive so the terminal has time to process 158 + await startServer(name, "sh", ["-c", "echo 'hello world'; sleep 30"]); 159 + 160 + // Small delay to let xterm-headless process the output 161 + await new Promise((r) => setTimeout(r, 200)); 162 + 163 + const client = await connect(name); 164 + const reader = new PacketReader(); 165 + 166 + client.write(encodeAttach(24, 80)); 167 + 168 + // The SCREEN packet should contain the buffered output 169 + const screenPacket = await waitForType(client, reader, MessageType.SCREEN); 170 + expect(screenPacket.payload.toString()).toContain("hello world"); 171 + 172 + client.destroy(); 173 + }); 174 + 175 + it("sends input to the PTY process", async () => { 176 + const name = uniqueName(); 177 + // `cat` echoes stdin back to stdout 178 + await startServer(name, "cat"); 179 + 180 + const client = await connect(name); 181 + const reader = new PacketReader(); 182 + 183 + client.write(encodeAttach(24, 80)); 184 + 185 + // Wait for initial SCREEN packet 186 + await waitForType(client, reader, MessageType.SCREEN); 187 + 188 + // Send some input 189 + client.write(encodeData("test input\n")); 190 + 191 + // Should get it echoed back as DATA 192 + const dataPacket = await waitForType(client, reader, MessageType.DATA, 3000); 193 + expect(dataPacket.payload.toString()).toContain("test input"); 194 + 195 + client.destroy(); 196 + }); 197 + 198 + it("detach and reattach with screen replay", async () => { 199 + const name = uniqueName(); 200 + // Use sh to print something then wait 201 + await startServer(name, "sh", ["-c", "echo 'persistent output'; sleep 30"]); 202 + 203 + // Let xterm-headless process the output 204 + await new Promise((r) => setTimeout(r, 200)); 205 + 206 + // First client: attach, get output, detach 207 + const client1 = await connect(name); 208 + const reader1 = new PacketReader(); 209 + 210 + client1.write(encodeAttach(24, 80)); 211 + 212 + // Collect output until we see our text 213 + const packets1 = await collectPackets(client1, reader1, 2, 3000); 214 + const output1 = packets1 215 + .filter((p) => p.type === MessageType.DATA || p.type === MessageType.SCREEN) 216 + .map((p) => p.payload.toString()) 217 + .join(""); 218 + expect(output1).toContain("persistent output"); 219 + 220 + // Detach 221 + client1.write(encodeDetach()); 222 + await new Promise((r) => client1.on("close", r)); 223 + 224 + // Second client: reattach, should get screen replay with the output 225 + const client2 = await connect(name); 226 + const reader2 = new PacketReader(); 227 + 228 + client2.write(encodeAttach(24, 80)); 229 + 230 + const screenPacket = await waitForType(client2, reader2, MessageType.SCREEN); 231 + expect(screenPacket.payload.toString()).toContain("persistent output"); 232 + 233 + client2.destroy(); 234 + }); 235 + 236 + it("receives EXIT when process terminates", async () => { 237 + const name = uniqueName(); 238 + // Process that exits with code 42 239 + await startServer(name, "sh", ["-c", "exit 42"]); 240 + 241 + const client = await connect(name); 242 + const reader = new PacketReader(); 243 + 244 + client.write(encodeAttach(24, 80)); 245 + 246 + const exitPacket = await waitForType(client, reader, MessageType.EXIT, 3000); 247 + expect(exitPacket.type).toBe(MessageType.EXIT); 248 + expect(decodeExit(exitPacket.payload)).toBe(42); 249 + 250 + client.destroy(); 251 + }); 252 + 253 + it("handles resize", async () => { 254 + const name = uniqueName(); 255 + await startServer(name, "cat"); 256 + 257 + const client = await connect(name); 258 + const reader = new PacketReader(); 259 + 260 + client.write(encodeAttach(24, 80)); 261 + await waitForType(client, reader, MessageType.SCREEN); 262 + 263 + // Resize — shouldn't crash or error 264 + client.write(encodeResize(48, 120)); 265 + 266 + // Send some data after resize to verify things still work 267 + client.write(encodeData("after resize\n")); 268 + 269 + const dataPacket = await waitForType(client, reader, MessageType.DATA, 3000); 270 + expect(dataPacket.payload.toString()).toContain("after resize"); 271 + 272 + client.destroy(); 273 + }); 274 + 275 + it("supports multiple simultaneous clients", async () => { 276 + const name = uniqueName(); 277 + await startServer(name, "cat"); 278 + 279 + const client1 = await connect(name); 280 + const reader1 = new PacketReader(); 281 + const client2 = await connect(name); 282 + const reader2 = new PacketReader(); 283 + 284 + client1.write(encodeAttach(24, 80)); 285 + client2.write(encodeAttach(24, 80)); 286 + 287 + await waitForType(client1, reader1, MessageType.SCREEN); 288 + await waitForType(client2, reader2, MessageType.SCREEN); 289 + 290 + // Client 1 sends input — both should receive the echo 291 + client1.write(encodeData("shared input\n")); 292 + 293 + const data1 = await waitForType(client1, reader1, MessageType.DATA, 3000); 294 + const data2 = await waitForType(client2, reader2, MessageType.DATA, 3000); 295 + 296 + expect(data1.payload.toString()).toContain("shared input"); 297 + expect(data2.payload.toString()).toContain("shared input"); 298 + 299 + client1.destroy(); 300 + client2.destroy(); 301 + }); 302 + 303 + it("cleans up socket and pid files on close", async () => { 304 + const name = uniqueName(); 305 + const server = await startServer(name, "cat"); 306 + 307 + const socketPath = getSocketPath(name); 308 + 309 + // Socket should exist 310 + const fs = await import("node:fs"); 311 + expect(fs.existsSync(socketPath)).toBe(true); 312 + 313 + await server.close(); 314 + 315 + // Socket should be gone 316 + expect(fs.existsSync(socketPath)).toBe(false); 317 + 318 + // Remove from tracking so afterEach doesn't double-close 319 + servers = servers.filter((s) => s !== server); 320 + }); 321 + 322 + it("peek receives screen replay without affecting the session", async () => { 323 + const name = uniqueName(); 324 + await startServer(name, "sh", ["-c", "echo 'peek test output'; sleep 30"]); 325 + await new Promise((r) => setTimeout(r, 200)); 326 + 327 + // Peek client 328 + const peeker = await connect(name); 329 + const peekReader = new PacketReader(); 330 + 331 + peeker.write(encodePeek()); 332 + 333 + const screenPacket = await waitForType(peeker, peekReader, MessageType.SCREEN); 334 + expect(screenPacket.payload.toString()).toContain("peek test output"); 335 + 336 + peeker.destroy(); 337 + }); 338 + 339 + it("peek client input is ignored by server", async () => { 340 + const name = uniqueName(); 341 + // Use cat — if input reaches it, it would echo back 342 + await startServer(name, "cat"); 343 + 344 + // Regular client to observe 345 + const watcher = await connect(name); 346 + const watchReader = new PacketReader(); 347 + watcher.write(encodeAttach(24, 80)); 348 + await waitForType(watcher, watchReader, MessageType.SCREEN); 349 + 350 + // Peek client sends DATA — server should ignore it 351 + const peeker = await connect(name); 352 + const peekReader = new PacketReader(); 353 + peeker.write(encodePeek()); 354 + await waitForType(peeker, peekReader, MessageType.SCREEN); 355 + 356 + peeker.write(encodeData("this should be ignored\n")); 357 + 358 + // Wait a bit — if the input were forwarded, cat would echo it back 359 + const packets = await collectPackets(watcher, watchReader, 1, 500); 360 + const echoed = packets 361 + .filter((p) => p.type === MessageType.DATA) 362 + .map((p) => p.payload.toString()) 363 + .join(""); 364 + expect(echoed).not.toContain("this should be ignored"); 365 + 366 + watcher.destroy(); 367 + peeker.destroy(); 368 + }); 369 + 370 + it("peek client does not affect terminal size", async () => { 371 + const name = uniqueName(); 372 + await startServer(name, "cat", [], { rows: 24, cols: 80 }); 373 + 374 + // Attach a regular client with specific size 375 + const client = await connect(name); 376 + const clientReader = new PacketReader(); 377 + client.write(encodeAttach(30, 100)); 378 + await waitForType(client, clientReader, MessageType.SCREEN); 379 + 380 + // Peek client sends RESIZE — server should ignore it 381 + const peeker = await connect(name); 382 + const peekReader = new PacketReader(); 383 + peeker.write(encodePeek()); 384 + await waitForType(peeker, peekReader, MessageType.SCREEN); 385 + peeker.write(encodeResize(10, 10)); 386 + 387 + // Send input through the regular client — if resize happened, output would differ 388 + // Just verify no crash and the session is still functional 389 + client.write(encodeData("still works\n")); 390 + const data = await waitForType(client, clientReader, MessageType.DATA, 3000); 391 + expect(data.payload.toString()).toContain("still works"); 392 + 393 + client.destroy(); 394 + peeker.destroy(); 395 + }); 396 + 397 + it("peek receives live DATA when following", async () => { 398 + const name = uniqueName(); 399 + await startServer(name, "cat"); 400 + 401 + const peeker = await connect(name); 402 + const peekReader = new PacketReader(); 403 + peeker.write(encodePeek()); 404 + await waitForType(peeker, peekReader, MessageType.SCREEN); 405 + 406 + // Regular client sends input 407 + const client = await connect(name); 408 + const clientReader = new PacketReader(); 409 + client.write(encodeAttach(24, 80)); 410 + await waitForType(client, clientReader, MessageType.SCREEN); 411 + 412 + client.write(encodeData("live data\n")); 413 + 414 + // Peeker should receive the echoed output as DATA 415 + const dataPacket = await waitForType(peeker, peekReader, MessageType.DATA, 3000); 416 + expect(dataPacket.payload.toString()).toContain("live data"); 417 + 418 + client.destroy(); 419 + peeker.destroy(); 420 + }); 421 + 422 + it("writes session metadata on creation", async () => { 423 + const name = uniqueName(); 424 + await startServer(name, "cat", ["-u"]); 425 + 426 + const meta = readMetadata(name); 427 + expect(meta).not.toBeNull(); 428 + expect(meta!.command).toBe("cat"); 429 + expect(meta!.args).toEqual(["-u"]); 430 + expect(meta!.createdAt).toBeTruthy(); 431 + expect(meta!.exitCode).toBeUndefined(); 432 + }); 433 + 434 + it("screen replay includes scrollback content", async () => { 435 + const name = uniqueName(); 436 + // Generate enough output to scroll past the visible 24 rows 437 + const lines = Array.from({ length: 40 }, (_, i) => `scrollback-line-${i}`); 438 + const script = lines.map((l) => `echo '${l}'`).join("; "); 439 + await startServer(name, "sh", ["-c", `${script}; sleep 30`]); 440 + 441 + await new Promise((r) => setTimeout(r, 300)); 442 + 443 + const client = await connect(name); 444 + const reader = new PacketReader(); 445 + client.write(encodeAttach(24, 80)); 446 + 447 + const screenPacket = await waitForType(client, reader, MessageType.SCREEN); 448 + const screen = screenPacket.payload.toString(); 449 + 450 + // The screen replay should include content that scrolled off — at minimum the last visible lines 451 + expect(screen).toContain("scrollback-line-39"); 452 + // And earlier lines that are now in scrollback 453 + expect(screen).toContain("scrollback-line-0"); 454 + 455 + client.destroy(); 456 + }); 457 + 458 + it("saves last lines and exit code on process exit", async () => { 459 + const name = uniqueName(); 460 + const server = await startServer(name, "sh", [ 461 + "-c", 462 + "echo 'line one'; echo 'line two'; echo 'line three'; sleep 0.2; exit 7", 463 + ]); 464 + 465 + // Wait for the process to exit and metadata to be written 466 + await new Promise((r) => setTimeout(r, 500)); 467 + 468 + const meta = readMetadata(name); 469 + expect(meta).not.toBeNull(); 470 + expect(meta!.exitCode).toBe(7); 471 + expect(meta!.exitedAt).toBeTruthy(); 472 + expect(meta!.lastLines).toBeDefined(); 473 + expect(meta!.lastLines!.some((l) => l.includes("line one"))).toBe(true); 474 + expect(meta!.lastLines!.some((l) => l.includes("line three"))).toBe(true); 475 + }); 476 + 477 + it("metadata persists after server closes", async () => { 478 + const name = uniqueName(); 479 + const server = await startServer(name, "sh", ["-c", "echo 'persist me'; sleep 0.2; exit 0"]); 480 + 481 + await new Promise((r) => setTimeout(r, 500)); 482 + await server.close(); 483 + servers = servers.filter((s) => s !== server); 484 + 485 + // Socket should be gone but metadata should remain 486 + const fs = await import("node:fs"); 487 + expect(fs.existsSync(getSocketPath(name))).toBe(false); 488 + expect(fs.existsSync(getMetadataPath(name))).toBe(true); 489 + 490 + const meta = readMetadata(name); 491 + expect(meta!.lastLines!.some((l) => l.includes("persist me"))).toBe(true); 492 + 493 + // Clean up metadata 494 + cleanupAll(name); 495 + }); 496 + 497 + it("validates session names", () => { 498 + expect(() => validateName("good-name")).not.toThrow(); 499 + expect(() => validateName("my.session_1")).not.toThrow(); 500 + expect(() => validateName("")).toThrow(/empty/); 501 + expect(() => validateName("bad/name")).toThrow(/Invalid session name/); 502 + expect(() => validateName("../traversal")).toThrow(/Invalid session name/); 503 + expect(() => validateName("has spaces")).toThrow(/Invalid session name/); 504 + expect(() => validateName("a".repeat(256))).toThrow(/too long/); 505 + }); 506 + 507 + it("lock prevents double acquire, release allows reacquire", () => { 508 + const name = uniqueName(); 509 + expect(acquireLock(name)).toBe(true); 510 + // Same process — should fail since we already hold it 511 + // (lock file exists with our PID, and we're alive) 512 + expect(acquireLock(name)).toBe(false); 513 + releaseLock(name); 514 + // After release, should succeed 515 + expect(acquireLock(name)).toBe(true); 516 + releaseLock(name); 517 + }); 518 + 519 + it("lock with garbage content is treated as stale", async () => { 520 + const name = uniqueName(); 521 + const fs = await import("node:fs"); 522 + const { ensureSessionDir } = await import("../src/sessions.ts"); 523 + ensureSessionDir(); 524 + 525 + // Write garbage to the lock file 526 + const lockPath = getSocketPath(name).replace(".sock", ".lock"); 527 + fs.writeFileSync(lockPath, "not-a-pid"); 528 + 529 + // Should steal the lock 530 + expect(acquireLock(name)).toBe(true); 531 + releaseLock(name); 532 + }); 533 + 534 + it("last attached client wins for terminal size", async () => { 535 + const name = uniqueName(); 536 + // Use tput to print terminal dimensions — it reads from the PTY 537 + await startServer(name, "cat", [], { rows: 24, cols: 80 }); 538 + 539 + // Client 1 attaches with 30x100 540 + const client1 = await connect(name); 541 + const reader1 = new PacketReader(); 542 + client1.write(encodeAttach(30, 100)); 543 + await waitForType(client1, reader1, MessageType.SCREEN); 544 + 545 + // Client 2 attaches with 40x120 546 + const client2 = await connect(name); 547 + const reader2 = new PacketReader(); 548 + client2.write(encodeAttach(40, 120)); 549 + await waitForType(client2, reader2, MessageType.SCREEN); 550 + 551 + // Now client 1 re-attaches with 50x150 — should win because it's most recent 552 + client1.write(encodeAttach(50, 150)); 553 + await waitForType(client1, reader1, MessageType.SCREEN); 554 + 555 + // Verify the session still works and the size was applied 556 + // (We can't easily read back the PTY size, but we can confirm no crash 557 + // and that input/output still works after the re-attach) 558 + client1.write(encodeData("size-test\n")); 559 + const data = await waitForType(client1, reader1, MessageType.DATA, 3000); 560 + expect(data.payload.toString()).toContain("size-test"); 561 + 562 + client1.destroy(); 563 + client2.destroy(); 564 + }); 565 + 566 + it("server handles truncated ATTACH payload gracefully", async () => { 567 + const name = uniqueName(); 568 + await startServer(name, "cat"); 569 + 570 + const client = await connect(name); 571 + const reader = new PacketReader(); 572 + 573 + // Send a malformed ATTACH with only 2 bytes payload instead of 4 574 + const { encodePacket, MessageType: MT } = await import( 575 + "../src/protocol.ts" 576 + ); 577 + const badAttach = encodePacket(MT.ATTACH, Buffer.alloc(2)); 578 + client.write(badAttach); 579 + 580 + // Send a proper ATTACH after — server should still work 581 + client.write(encodeAttach(24, 80)); 582 + const screen = await waitForType(client, reader, MessageType.SCREEN); 583 + expect(screen.type).toBe(MessageType.SCREEN); 584 + 585 + client.destroy(); 586 + }); 587 + 588 + it("server handles truncated RESIZE payload gracefully", async () => { 589 + const name = uniqueName(); 590 + await startServer(name, "cat"); 591 + 592 + const client = await connect(name); 593 + const reader = new PacketReader(); 594 + client.write(encodeAttach(24, 80)); 595 + await waitForType(client, reader, MessageType.SCREEN); 596 + 597 + // Send malformed RESIZE with 1 byte payload 598 + const { encodePacket, MessageType: MT } = await import( 599 + "../src/protocol.ts" 600 + ); 601 + client.write(encodePacket(MT.RESIZE, Buffer.alloc(1))); 602 + 603 + // Session should still work 604 + client.write(encodeData("after-bad-resize\n")); 605 + const data = await waitForType(client, reader, MessageType.DATA, 3000); 606 + expect(data.payload.toString()).toContain("after-bad-resize"); 607 + 608 + client.destroy(); 609 + }); 610 + 611 + it("server ignores unknown message types", async () => { 612 + const name = uniqueName(); 613 + await startServer(name, "cat"); 614 + 615 + const client = await connect(name); 616 + const reader = new PacketReader(); 617 + client.write(encodeAttach(24, 80)); 618 + await waitForType(client, reader, MessageType.SCREEN); 619 + 620 + // Send a packet with unknown type 99 621 + const header = Buffer.alloc(5); 622 + header.writeUInt8(99, 0); 623 + header.writeUInt32BE(3, 1); 624 + client.write(Buffer.concat([header, Buffer.from("abc")])); 625 + 626 + // Session should still work 627 + client.write(encodeData("after-unknown\n")); 628 + const data = await waitForType(client, reader, MessageType.DATA, 3000); 629 + expect(data.payload.toString()).toContain("after-unknown"); 630 + 631 + client.destroy(); 632 + }); 633 + });
+188
tests/protocol.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { 3 + MessageType, 4 + PacketReader, 5 + encodePacket, 6 + encodeData, 7 + encodeAttach, 8 + encodeDetach, 9 + encodeResize, 10 + encodeExit, 11 + encodeScreen, 12 + decodeSize, 13 + decodeExit, 14 + } from "../src/protocol.ts"; 15 + 16 + describe("protocol", () => { 17 + describe("encodePacket / PacketReader", () => { 18 + it("round-trips a DATA packet", () => { 19 + const reader = new PacketReader(); 20 + const encoded = encodeData("hello world"); 21 + const packets = reader.feed(encoded); 22 + 23 + expect(packets).toHaveLength(1); 24 + expect(packets[0].type).toBe(MessageType.DATA); 25 + expect(packets[0].payload.toString()).toBe("hello world"); 26 + }); 27 + 28 + it("round-trips an ATTACH packet", () => { 29 + const reader = new PacketReader(); 30 + const encoded = encodeAttach(24, 80); 31 + const packets = reader.feed(encoded); 32 + 33 + expect(packets).toHaveLength(1); 34 + expect(packets[0].type).toBe(MessageType.ATTACH); 35 + 36 + const size = decodeSize(packets[0].payload); 37 + expect(size.rows).toBe(24); 38 + expect(size.cols).toBe(80); 39 + }); 40 + 41 + it("round-trips a DETACH packet", () => { 42 + const reader = new PacketReader(); 43 + const encoded = encodeDetach(); 44 + const packets = reader.feed(encoded); 45 + 46 + expect(packets).toHaveLength(1); 47 + expect(packets[0].type).toBe(MessageType.DETACH); 48 + expect(packets[0].payload.length).toBe(0); 49 + }); 50 + 51 + it("round-trips a RESIZE packet", () => { 52 + const reader = new PacketReader(); 53 + const encoded = encodeResize(48, 120); 54 + const packets = reader.feed(encoded); 55 + 56 + expect(packets).toHaveLength(1); 57 + const size = decodeSize(packets[0].payload); 58 + expect(size.rows).toBe(48); 59 + expect(size.cols).toBe(120); 60 + }); 61 + 62 + it("round-trips an EXIT packet", () => { 63 + const reader = new PacketReader(); 64 + const encoded = encodeExit(42); 65 + const packets = reader.feed(encoded); 66 + 67 + expect(packets).toHaveLength(1); 68 + expect(packets[0].type).toBe(MessageType.EXIT); 69 + expect(decodeExit(packets[0].payload)).toBe(42); 70 + }); 71 + 72 + it("round-trips a SCREEN packet", () => { 73 + const reader = new PacketReader(); 74 + const screen = "\x1b[2J\x1b[H$ hello\r\nworld"; 75 + const encoded = encodeScreen(screen); 76 + const packets = reader.feed(encoded); 77 + 78 + expect(packets).toHaveLength(1); 79 + expect(packets[0].type).toBe(MessageType.SCREEN); 80 + expect(packets[0].payload.toString()).toBe(screen); 81 + }); 82 + }); 83 + 84 + describe("PacketReader streaming", () => { 85 + it("handles multiple packets in one chunk", () => { 86 + const reader = new PacketReader(); 87 + const buf = Buffer.concat([ 88 + encodeData("hello"), 89 + encodeData("world"), 90 + encodeDetach(), 91 + ]); 92 + 93 + const packets = reader.feed(buf); 94 + expect(packets).toHaveLength(3); 95 + expect(packets[0].payload.toString()).toBe("hello"); 96 + expect(packets[1].payload.toString()).toBe("world"); 97 + expect(packets[2].type).toBe(MessageType.DETACH); 98 + }); 99 + 100 + it("handles packets split across multiple chunks", () => { 101 + const reader = new PacketReader(); 102 + const full = encodeData("hello world"); 103 + 104 + // Split in the middle 105 + const part1 = full.subarray(0, 3); 106 + const part2 = full.subarray(3, 8); 107 + const part3 = full.subarray(8); 108 + 109 + expect(reader.feed(part1)).toHaveLength(0); 110 + expect(reader.feed(part2)).toHaveLength(0); 111 + 112 + const packets = reader.feed(part3); 113 + expect(packets).toHaveLength(1); 114 + expect(packets[0].payload.toString()).toBe("hello world"); 115 + }); 116 + 117 + it("handles a packet split exactly at the header boundary", () => { 118 + const reader = new PacketReader(); 119 + const full = encodeData("test"); 120 + 121 + // Split exactly after the 5-byte header 122 + const header = full.subarray(0, 5); 123 + const payload = full.subarray(5); 124 + 125 + expect(reader.feed(header)).toHaveLength(0); 126 + const packets = reader.feed(payload); 127 + expect(packets).toHaveLength(1); 128 + expect(packets[0].payload.toString()).toBe("test"); 129 + }); 130 + 131 + it("handles empty payload", () => { 132 + const reader = new PacketReader(); 133 + const encoded = encodePacket(MessageType.DETACH, Buffer.alloc(0)); 134 + const packets = reader.feed(encoded); 135 + 136 + expect(packets).toHaveLength(1); 137 + expect(packets[0].type).toBe(MessageType.DETACH); 138 + expect(packets[0].payload.length).toBe(0); 139 + }); 140 + 141 + it("handles large payloads", () => { 142 + const reader = new PacketReader(); 143 + const bigString = "x".repeat(100_000); 144 + const encoded = encodeData(bigString); 145 + const packets = reader.feed(encoded); 146 + 147 + expect(packets).toHaveLength(1); 148 + expect(packets[0].payload.toString()).toBe(bigString); 149 + }); 150 + 151 + it("ignores unknown message types without crashing", () => { 152 + const reader = new PacketReader(); 153 + // Manually craft a packet with type 99 154 + const header = Buffer.alloc(5); 155 + header.writeUInt8(99, 0); 156 + header.writeUInt32BE(3, 1); 157 + const payload = Buffer.from("abc"); 158 + const raw = Buffer.concat([header, payload]); 159 + 160 + const packets = reader.feed(raw); 161 + expect(packets).toHaveLength(1); 162 + expect(packets[0].type).toBe(99); 163 + expect(packets[0].payload.toString()).toBe("abc"); 164 + }); 165 + }); 166 + 167 + describe("decode edge cases", () => { 168 + it("decodeSize returns defaults for truncated payload", () => { 169 + const size = decodeSize(Buffer.alloc(2)); 170 + expect(size.rows).toBe(24); 171 + expect(size.cols).toBe(80); 172 + }); 173 + 174 + it("decodeSize returns defaults for empty payload", () => { 175 + const size = decodeSize(Buffer.alloc(0)); 176 + expect(size.rows).toBe(24); 177 + expect(size.cols).toBe(80); 178 + }); 179 + 180 + it("decodeExit returns -1 for truncated payload", () => { 181 + expect(decodeExit(Buffer.alloc(2))).toBe(-1); 182 + }); 183 + 184 + it("decodeExit returns -1 for empty payload", () => { 185 + expect(decodeExit(Buffer.alloc(0))).toBe(-1); 186 + }); 187 + }); 188 + });
+1420
tests/screenshot.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as net from "node:net"; 3 + import * as fs from "node:fs"; 4 + import * as os from "node:os"; 5 + import * as path from "node:path"; 6 + import { spawn } from "node:child_process"; 7 + import { fileURLToPath } from "node:url"; 8 + import { Terminal } from "@xterm/headless"; 9 + import { SerializeAddon } from "@xterm/addon-serialize"; 10 + import { PtyServer, type ServerOptions } from "../src/server.ts"; 11 + import { 12 + MessageType, 13 + PacketReader, 14 + encodeAttach, 15 + encodeData, 16 + encodeResize, 17 + } from "../src/protocol.ts"; 18 + import { getSocketPath, cleanupAll } from "../src/sessions.ts"; 19 + 20 + // All tests run in a tmp directory to avoid polluting the project 21 + const testCwd = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-")); 22 + afterAll(() => { 23 + fs.rmSync(testCwd, { recursive: true, force: true }); 24 + }); 25 + 26 + // ─── Types ─── 27 + 28 + interface Screenshot { 29 + /** Plain text lines (trailing whitespace trimmed per line) */ 30 + lines: string[]; 31 + /** All lines joined with newline */ 32 + text: string; 33 + /** ANSI-serialized terminal state (includes escape codes) */ 34 + ansi: string; 35 + } 36 + 37 + // ─── TestSession ─── 38 + 39 + class TestSession { 40 + server: PtyServer; 41 + name: string; 42 + rows: number; 43 + cols: number; 44 + 45 + private ownsServer: boolean; 46 + private socket!: net.Socket; 47 + private reader!: PacketReader; 48 + private terminal: Terminal; 49 + private serialize: SerializeAddon; 50 + private screenCallbacks: Array<() => void> = []; 51 + private exitCode: number | null = null; 52 + 53 + private constructor( 54 + server: PtyServer, 55 + name: string, 56 + rows: number, 57 + cols: number, 58 + ownsServer: boolean 59 + ) { 60 + this.server = server; 61 + this.name = name; 62 + this.rows = rows; 63 + this.cols = cols; 64 + this.ownsServer = ownsServer; 65 + this.terminal = new Terminal({ 66 + rows, 67 + cols, 68 + scrollback: 1000, 69 + allowProposedApi: true, 70 + }); 71 + this.serialize = new SerializeAddon(); 72 + this.terminal.loadAddon(this.serialize); 73 + } 74 + 75 + static async create( 76 + name: string, 77 + command: string, 78 + args: string[] = [], 79 + opts: Partial<Pick<ServerOptions, "rows" | "cols">> & { cwd?: string } = {} 80 + ): Promise<TestSession> { 81 + const rows = opts.rows ?? 24; 82 + const cols = opts.cols ?? 80; 83 + const cwd = opts.cwd ?? testCwd; 84 + const server = new PtyServer({ 85 + name, 86 + command, 87 + args, 88 + displayCommand: command, 89 + cwd, 90 + rows, 91 + cols, 92 + }); 93 + await server.ready; 94 + const session = new TestSession(server, name, rows, cols, true); 95 + await session.connectSocket(); 96 + return session; 97 + } 98 + 99 + /** Connect to an existing server as a second client. */ 100 + static async connectToExisting( 101 + name: string, 102 + server: PtyServer, 103 + opts: { rows?: number; cols?: number } = {} 104 + ): Promise<TestSession> { 105 + const rows = opts.rows ?? 24; 106 + const cols = opts.cols ?? 80; 107 + const session = new TestSession(server, name, rows, cols, false); 108 + await session.connectSocket(); 109 + return session; 110 + } 111 + 112 + private async connectSocket(): Promise<void> { 113 + this.reader = new PacketReader(); 114 + this.screenCallbacks = []; 115 + this.exitCode = null; 116 + 117 + this.socket = await new Promise<net.Socket>((resolve, reject) => { 118 + const s = net.createConnection(getSocketPath(this.name)); 119 + s.on("connect", () => resolve(s)); 120 + s.on("error", reject); 121 + }); 122 + 123 + this.socket.on("data", (data: Buffer) => { 124 + const packets = this.reader.feed(data); 125 + for (const packet of packets) { 126 + switch (packet.type) { 127 + case MessageType.SCREEN: 128 + this.terminal.reset(); 129 + this.terminal.write(packet.payload.toString(), () => { 130 + const cbs = this.screenCallbacks; 131 + this.screenCallbacks = []; 132 + for (const cb of cbs) cb(); 133 + }); 134 + break; 135 + case MessageType.DATA: 136 + this.terminal.write(packet.payload.toString()); 137 + break; 138 + case MessageType.EXIT: 139 + this.exitCode = packet.payload.readInt32BE(0); 140 + break; 141 + } 142 + } 143 + }); 144 + } 145 + 146 + /** Send ATTACH and wait for the SCREEN response. */ 147 + async attach(): Promise<void> { 148 + const screenPromise = new Promise<void>((resolve) => { 149 + const timer = setTimeout(resolve, 5000); 150 + this.screenCallbacks.push(() => { 151 + clearTimeout(timer); 152 + resolve(); 153 + }); 154 + }); 155 + this.socket.write(encodeAttach(this.rows, this.cols)); 156 + await screenPromise; 157 + } 158 + 159 + /** Disconnect, create a new socket, and attach again. */ 160 + async reconnect(): Promise<void> { 161 + this.socket.destroy(); 162 + await new Promise((r) => setTimeout(r, 100)); 163 + this.terminal.reset(); 164 + await this.connectSocket(); 165 + await this.attach(); 166 + } 167 + 168 + /** Send keystrokes to the PTY process. */ 169 + sendKeys(keys: string): void { 170 + this.socket.write(encodeData(keys)); 171 + } 172 + 173 + /** Send a RESIZE message and update the local terminal dimensions. */ 174 + resize(rows: number, cols: number): void { 175 + this.rows = rows; 176 + this.cols = cols; 177 + this.socket.write(encodeResize(rows, cols)); 178 + this.terminal.resize(cols, rows); 179 + } 180 + 181 + /** Whether the PTY process has exited. */ 182 + get hasExited(): boolean { 183 + return this.exitCode !== null; 184 + } 185 + 186 + /** Capture the current terminal state. */ 187 + screenshot(): Screenshot { 188 + const buffer = this.terminal.buffer.active; 189 + const lines: string[] = []; 190 + for (let i = 0; i < buffer.length; i++) { 191 + const line = buffer.getLine(i); 192 + if (line) { 193 + lines.push(line.translateToString(true)); 194 + } 195 + } 196 + while (lines.length > 0 && lines[lines.length - 1].trim() === "") { 197 + lines.pop(); 198 + } 199 + return { 200 + lines, 201 + text: lines.join("\n"), 202 + ansi: this.serialize.serialize(), 203 + }; 204 + } 205 + 206 + /** Poll until the terminal contains the given text. */ 207 + async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 208 + const start = Date.now(); 209 + while (Date.now() - start < timeoutMs) { 210 + await new Promise((r) => setTimeout(r, 50)); 211 + const ss = this.screenshot(); 212 + if (ss.text.includes(text)) return ss; 213 + } 214 + const ss = this.screenshot(); 215 + throw new Error( 216 + `Timed out after ${timeoutMs}ms waiting for "${text}".\nScreen:\n${ss.text}` 217 + ); 218 + } 219 + 220 + /** Poll until a predicate on the screenshot returns true. */ 221 + async waitFor( 222 + predicate: (ss: Screenshot) => boolean, 223 + timeoutMs = 5000, 224 + description = "predicate" 225 + ): Promise<Screenshot> { 226 + const start = Date.now(); 227 + while (Date.now() - start < timeoutMs) { 228 + await new Promise((r) => setTimeout(r, 50)); 229 + const ss = this.screenshot(); 230 + if (predicate(ss)) return ss; 231 + } 232 + const ss = this.screenshot(); 233 + throw new Error( 234 + `Timed out after ${timeoutMs}ms waiting for ${description}.\nScreen:\n${ss.text}` 235 + ); 236 + } 237 + 238 + async close(): Promise<void> { 239 + this.socket.destroy(); 240 + this.terminal.dispose(); 241 + if (this.ownsServer) { 242 + await this.server.close(); 243 + } 244 + } 245 + } 246 + 247 + // ─── Scaffolding ─── 248 + 249 + let sessions: TestSession[] = []; 250 + let sessionNames: string[] = []; 251 + let tmpDirs: string[] = []; 252 + let daemonPids: number[] = []; 253 + 254 + function uniqueName(): string { 255 + const name = `ss-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; 256 + sessionNames.push(name); 257 + return name; 258 + } 259 + 260 + function makeTmpDir(): string { 261 + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ss-td-")); 262 + tmpDirs.push(dir); 263 + return dir; 264 + } 265 + 266 + async function createSession( 267 + command: string, 268 + args: string[] = [], 269 + opts: { rows?: number; cols?: number; cwd?: string } = {} 270 + ): Promise<TestSession> { 271 + const name = uniqueName(); 272 + const session = await TestSession.create(name, command, args, opts); 273 + sessions.push(session); 274 + await session.attach(); 275 + return session; 276 + } 277 + 278 + afterEach(async () => { 279 + for (const session of sessions) { 280 + await session.close(); 281 + } 282 + sessions = []; 283 + for (const pid of daemonPids) { 284 + try { 285 + process.kill(pid, "SIGTERM"); 286 + } catch {} 287 + } 288 + daemonPids = []; 289 + for (const name of sessionNames) { 290 + cleanupAll(name); 291 + } 292 + sessionNames = []; 293 + for (const dir of tmpDirs) { 294 + fs.rmSync(dir, { recursive: true, force: true }); 295 + } 296 + tmpDirs = []; 297 + }); 298 + 299 + // ─── Tests: ls ─── 300 + 301 + describe("screenshot: ls", () => { 302 + it("captures ls output with correct filenames", async () => { 303 + const dir = makeTmpDir(); 304 + fs.writeFileSync(path.join(dir, "alpha.txt"), ""); 305 + fs.writeFileSync(path.join(dir, "beta.log"), ""); 306 + fs.mkdirSync(path.join(dir, "gamma")); 307 + 308 + const session = await createSession("sh", ["-c", `ls ${dir}; sleep 30`]); 309 + 310 + const ss = await session.waitForText("alpha.txt"); 311 + expect(ss.text).toContain("alpha.txt"); 312 + expect(ss.text).toContain("beta.log"); 313 + expect(ss.text).toContain("gamma"); 314 + }); 315 + 316 + it("captures ls -la with permissions and structure", async () => { 317 + const dir = makeTmpDir(); 318 + fs.writeFileSync(path.join(dir, "readme.md"), "hello"); 319 + fs.mkdirSync(path.join(dir, "src")); 320 + 321 + const session = await createSession("sh", [ 322 + "-c", 323 + `ls -la ${dir}; sleep 30`, 324 + ]); 325 + 326 + const ss = await session.waitForText("readme.md"); 327 + expect(ss.text).toContain("readme.md"); 328 + expect(ss.text).toContain("src"); 329 + expect(ss.text).toMatch(/[drwx-]{10}/); 330 + expect(ss.text).toMatch(/total \d+/); 331 + }); 332 + 333 + it("preserves ANSI colors from ls", async () => { 334 + const dir = makeTmpDir(); 335 + fs.writeFileSync(path.join(dir, "file.txt"), ""); 336 + fs.mkdirSync(path.join(dir, "directory")); 337 + 338 + const session = await createSession("sh", [ 339 + "-c", 340 + `CLICOLOR_FORCE=1 ls -G ${dir}; sleep 30`, 341 + ]); 342 + 343 + const ss = await session.waitForText("file.txt"); 344 + expect(ss.text).toContain("file.txt"); 345 + expect(ss.text).toContain("directory"); 346 + expect(ss.ansi).toMatch(/\x1b\[/); 347 + }); 348 + }); 349 + 350 + // ─── Tests: ANSI colors ─── 351 + 352 + describe("screenshot: ANSI colors", () => { 353 + it("preserves explicit ANSI color codes", async () => { 354 + const session = await createSession("sh", [ 355 + "-c", 356 + "printf '\\033[31mRED\\033[0m \\033[32mGREEN\\033[0m \\033[34mBLUE\\033[0m\\n'; sleep 30", 357 + ]); 358 + 359 + const ss = await session.waitForText("RED"); 360 + expect(ss.lines[0]).toContain("RED"); 361 + expect(ss.lines[0]).toContain("GREEN"); 362 + expect(ss.lines[0]).toContain("BLUE"); 363 + expect(ss.ansi).toMatch(/\x1b\[31m/); 364 + expect(ss.ansi).toMatch(/\x1b\[32m/); 365 + expect(ss.ansi).toMatch(/\x1b\[34m/); 366 + }); 367 + 368 + it("preserves 256-color codes", async () => { 369 + const session = await createSession("sh", [ 370 + "-c", 371 + "printf '\\033[38;5;208mORANGE\\033[0m\\n'; sleep 30", 372 + ]); 373 + 374 + const ss = await session.waitForText("ORANGE"); 375 + expect(ss.lines[0]).toContain("ORANGE"); 376 + expect(ss.ansi).toMatch(/\x1b\[38;5;208m/); 377 + }); 378 + 379 + it("preserves true color (24-bit) codes", async () => { 380 + const session = await createSession("sh", [ 381 + "-c", 382 + "printf '\\033[38;2;255;100;0mTRUECOLOR\\033[0m\\n'; sleep 30", 383 + ]); 384 + 385 + const ss = await session.waitForText("TRUECOLOR"); 386 + expect(ss.lines[0]).toContain("TRUECOLOR"); 387 + expect(ss.ansi).toMatch(/\x1b\[38;2;255;100;0m/); 388 + }); 389 + 390 + it("colors survive detach/reattach screen replay", async () => { 391 + const session = await createSession("sh", [ 392 + "-c", 393 + "printf '\\033[31mRED TEXT\\033[0m\\n'; sleep 30", 394 + ]); 395 + 396 + await session.waitForText("RED TEXT"); 397 + await session.reconnect(); 398 + 399 + const ss = await session.waitForText("RED TEXT"); 400 + expect(ss.text).toContain("RED TEXT"); 401 + expect(ss.ansi).toMatch(/\x1b\[31m/); 402 + }); 403 + }); 404 + 405 + // ─── Tests: hyperlinks (OSC 8) ─── 406 + 407 + describe("screenshot: hyperlinks (OSC 8)", () => { 408 + it("displays hyperlink text correctly", async () => { 409 + const session = await createSession("sh", [ 410 + "-c", 411 + "printf '\\e]8;;http://example.com\\e\\\\link text\\e]8;;\\e\\\\\\n'; sleep 30", 412 + ]); 413 + 414 + const ss = await session.waitForText("link text"); 415 + expect(ss.text).toContain("link text"); 416 + }); 417 + 418 + it("hyperlink text is rendered with underline styling", async () => { 419 + const session = await createSession("sh", [ 420 + "-c", 421 + "printf '\\e]8;;http://example.com\\e\\\\linked\\e]8;;\\e\\\\\\n'; sleep 30", 422 + ]); 423 + 424 + const ss = await session.waitForText("linked"); 425 + expect(ss.text).toContain("linked"); 426 + expect(ss.ansi).toContain("linked"); 427 + }); 428 + }); 429 + 430 + // ─── Tests: vim ─── 431 + 432 + describe("screenshot: vim", () => { 433 + it( 434 + "captures vim welcome screen", 435 + async () => { 436 + const session = await createSession("vim", ["--clean"], { 437 + rows: 24, 438 + cols: 80, 439 + }); 440 + 441 + const ss = await session.waitForText("VIM - Vi IMproved", 10000); 442 + expect(ss.text).toContain("VIM - Vi IMproved"); 443 + expect(ss.text).toMatch(/type\s+:q/i); 444 + expect(ss.lines.some((l) => l.trimStart().startsWith("~"))).toBe(true); 445 + 446 + session.sendKeys(":q\n"); 447 + }, 448 + 15000 449 + ); 450 + 451 + it( 452 + "shows INSERT mode and typed text", 453 + async () => { 454 + const session = await createSession("vim", ["--clean"], { 455 + rows: 24, 456 + cols: 80, 457 + }); 458 + 459 + await session.waitForText("VIM", 10000); 460 + 461 + session.sendKeys("i"); 462 + await session.waitForText("INSERT"); 463 + 464 + session.sendKeys("Hello from pty screenshot tests!"); 465 + const ss = await session.waitForText("Hello from pty screenshot tests!"); 466 + 467 + expect(ss.text).toContain("Hello from pty screenshot tests!"); 468 + expect(ss.text).toMatch(/INSERT/i); 469 + 470 + session.sendKeys("\x1b"); 471 + session.sendKeys(":q!\n"); 472 + }, 473 + 15000 474 + ); 475 + 476 + it( 477 + "vim screen is restored after detach/reattach", 478 + async () => { 479 + const session = await createSession("vim", ["--clean"], { 480 + rows: 24, 481 + cols: 80, 482 + }); 483 + 484 + await session.waitForText("VIM", 10000); 485 + 486 + session.sendKeys("iThis text survives detach"); 487 + await session.waitForText("This text survives detach"); 488 + session.sendKeys("\x1b"); 489 + 490 + await session.reconnect(); 491 + 492 + const ss = await session.waitForText("This text survives detach"); 493 + expect(ss.text).toContain("This text survives detach"); 494 + 495 + session.sendKeys(":q!\n"); 496 + }, 497 + 15000 498 + ); 499 + 500 + it( 501 + "captures vim with syntax highlighting", 502 + async () => { 503 + const dir = makeTmpDir(); 504 + const filePath = path.join(dir, "test.js"); 505 + fs.writeFileSync( 506 + filePath, 507 + 'function hello() {\n return "world";\n}\n' 508 + ); 509 + 510 + const session = await createSession( 511 + "vim", 512 + ["--clean", "+syntax on", filePath], 513 + { rows: 24, cols: 80 } 514 + ); 515 + 516 + const ss = await session.waitForText("function", 10000); 517 + expect(ss.text).toContain("function hello()"); 518 + expect(ss.text).toContain("return"); 519 + expect(ss.ansi).toMatch(/\x1b\[/); 520 + 521 + session.sendKeys(":q\n"); 522 + }, 523 + 15000 524 + ); 525 + }); 526 + 527 + // ─── Tests: nano ─── 528 + 529 + describe("screenshot: nano", () => { 530 + it( 531 + "captures nano interface elements", 532 + async () => { 533 + const dir = makeTmpDir(); 534 + const filePath = path.join(dir, "test.txt"); 535 + fs.writeFileSync(filePath, "Hello nano world\n"); 536 + 537 + const session = await createSession("nano", [filePath], { 538 + rows: 24, 539 + cols: 80, 540 + }); 541 + 542 + const ss = await session.waitFor( 543 + (s) => s.text.includes("nano") || s.text.includes("File:"), 544 + 10000, 545 + "nano UI" 546 + ); 547 + expect(ss.text).toContain("test.txt"); 548 + expect(ss.text).toContain("Hello nano world"); 549 + expect(ss.text).toContain("^X"); 550 + 551 + session.sendKeys("\x18"); 552 + }, 553 + 15000 554 + ); 555 + 556 + it( 557 + "nano typing updates the screen", 558 + async () => { 559 + const dir = makeTmpDir(); 560 + const filePath = path.join(dir, "edit.txt"); 561 + fs.writeFileSync(filePath, ""); 562 + 563 + const session = await createSession("nano", [filePath], { 564 + rows: 24, 565 + cols: 80, 566 + }); 567 + 568 + // nano title bar varies by version — wait for the shortcut bar 569 + await session.waitFor( 570 + (ss) => ss.text.includes("^G") && ss.text.includes("^X"), 571 + 10000, 572 + "nano UI" 573 + ); 574 + 575 + session.sendKeys("Typed in nano!"); 576 + const ss = await session.waitForText("Typed in nano!"); 577 + expect(ss.text).toContain("Typed in nano!"); 578 + 579 + session.sendKeys("\x18"); 580 + await session.waitForText("Save", 3000).catch(() => {}); 581 + session.sendKeys("n"); 582 + }, 583 + 15000 584 + ); 585 + }); 586 + 587 + // ─── Tests: screen replay fidelity ─── 588 + 589 + describe("screenshot: screen replay fidelity", () => { 590 + it("multi-line colored output survives replay", async () => { 591 + const script = [ 592 + "printf '\\033[1;31m=== HEADER ===\\033[0m\\n'", 593 + "printf '\\033[32mLine 1: success\\033[0m\\n'", 594 + "printf '\\033[33mLine 2: warning\\033[0m\\n'", 595 + "printf '\\033[31mLine 3: error\\033[0m\\n'", 596 + "printf '\\033[1;34m=== FOOTER ===\\033[0m\\n'", 597 + "sleep 30", 598 + ].join("; "); 599 + 600 + const session = await createSession("sh", ["-c", script]); 601 + await session.waitForText("FOOTER"); 602 + 603 + let ss = session.screenshot(); 604 + expect(ss.text).toContain("=== HEADER ==="); 605 + expect(ss.text).toContain("Line 1: success"); 606 + expect(ss.text).toContain("Line 2: warning"); 607 + expect(ss.text).toContain("Line 3: error"); 608 + expect(ss.text).toContain("=== FOOTER ==="); 609 + 610 + await session.reconnect(); 611 + 612 + ss = await session.waitForText("FOOTER"); 613 + expect(ss.text).toContain("=== HEADER ==="); 614 + expect(ss.text).toContain("Line 3: error"); 615 + expect(ss.text).toContain("=== FOOTER ==="); 616 + expect(ss.ansi).toMatch(/\x1b\[(1;31|31;1)m/); 617 + expect(ss.ansi).toMatch(/\x1b\[32/); 618 + expect(ss.ansi).toMatch(/\x1b\[33m/); 619 + expect(ss.ansi).toMatch(/\x1b\[31m/); 620 + expect(ss.ansi).toMatch(/\x1b\[(1;34|34;1)m/); 621 + }); 622 + 623 + it("cursor position is preserved in screen replay", async () => { 624 + const session = await createSession("sh", [ 625 + "-c", 626 + "printf '\\033[5;10HPositioned!'; sleep 30", 627 + ]); 628 + 629 + let ss = await session.waitForText("Positioned!"); 630 + expect(ss.lines[4]).toMatch(/\s{5,}Positioned!/); 631 + 632 + await session.reconnect(); 633 + ss = await session.waitForText("Positioned!"); 634 + expect(ss.lines[4]).toMatch(/\s{5,}Positioned!/); 635 + }); 636 + 637 + it("scrollback content is preserved in replay", async () => { 638 + const lines = Array.from({ length: 40 }, (_, i) => `scroll-line-${i}`); 639 + const script = lines.map((l) => `echo '${l}'`).join("; ") + "; sleep 30"; 640 + 641 + const session = await createSession("sh", ["-c", script]); 642 + await session.waitForText("scroll-line-39"); 643 + 644 + let ss = session.screenshot(); 645 + expect(ss.text).toContain("scroll-line-0"); 646 + expect(ss.text).toContain("scroll-line-39"); 647 + 648 + await session.reconnect(); 649 + 650 + ss = await session.waitForText("scroll-line-39"); 651 + expect(ss.text).toContain("scroll-line-0"); 652 + expect(ss.text).toContain("scroll-line-39"); 653 + }); 654 + }); 655 + 656 + // ─── Tests: shell interaction ─── 657 + 658 + describe("screenshot: shell interaction", () => { 659 + it( 660 + "interactive bash session with prompt and commands", 661 + async () => { 662 + const session = await createSession("bash", [ 663 + "--norc", 664 + "--noprofile", 665 + ]); 666 + 667 + await session.waitFor( 668 + (ss) => /[$#]/.test(ss.text), 669 + 5000, 670 + "shell prompt" 671 + ); 672 + 673 + session.sendKeys("echo hello-from-shell\n"); 674 + const ss = await session.waitForText("hello-from-shell"); 675 + expect(ss.text).toContain("hello-from-shell"); 676 + 677 + session.sendKeys("exit\n"); 678 + }, 679 + 15000 680 + ); 681 + 682 + it( 683 + "runs multiple commands in a shell session", 684 + async () => { 685 + const session = await createSession("bash", [ 686 + "--norc", 687 + "--noprofile", 688 + ]); 689 + 690 + await session.waitFor( 691 + (ss) => /[$#]/.test(ss.text), 692 + 5000, 693 + "prompt" 694 + ); 695 + 696 + session.sendKeys("echo 'first-cmd'\n"); 697 + await session.waitForText("first-cmd"); 698 + 699 + session.sendKeys("echo 'second-cmd'\n"); 700 + await session.waitForText("second-cmd"); 701 + 702 + session.sendKeys("echo 'third-cmd'\n"); 703 + const ss = await session.waitForText("third-cmd"); 704 + 705 + expect(ss.text).toContain("first-cmd"); 706 + expect(ss.text).toContain("second-cmd"); 707 + expect(ss.text).toContain("third-cmd"); 708 + 709 + session.sendKeys("exit\n"); 710 + }, 711 + 15000 712 + ); 713 + }); 714 + 715 + // ─── Tests: control characters / signals ─── 716 + 717 + describe("screenshot: control characters", () => { 718 + it( 719 + "Ctrl+C interrupts a running command", 720 + async () => { 721 + const session = await createSession("bash", [ 722 + "--norc", 723 + "--noprofile", 724 + ]); 725 + 726 + await session.waitFor( 727 + (ss) => /[$#]/.test(ss.text), 728 + 5000, 729 + "prompt" 730 + ); 731 + 732 + session.sendKeys("sleep 999\n"); 733 + await new Promise((r) => setTimeout(r, 300)); 734 + 735 + session.sendKeys("\x03"); // Ctrl+C 736 + await new Promise((r) => setTimeout(r, 300)); 737 + 738 + // Should be able to run another command after interrupt 739 + session.sendKeys("echo 'interrupted-ok'\n"); 740 + const ss = await session.waitForText("interrupted-ok"); 741 + expect(ss.text).toContain("interrupted-ok"); 742 + 743 + session.sendKeys("exit\n"); 744 + }, 745 + 15000 746 + ); 747 + 748 + it( 749 + "Ctrl+D sends EOF to close a program", 750 + async () => { 751 + const session = await createSession("cat"); 752 + 753 + session.sendKeys("hello-eof-test\n"); 754 + await session.waitForText("hello-eof-test"); 755 + 756 + // Ctrl+D on an empty line signals EOF 757 + session.sendKeys("\x04"); 758 + 759 + // cat should exit after receiving EOF 760 + await new Promise((r) => setTimeout(r, 500)); 761 + expect(session.hasExited).toBe(true); 762 + }, 763 + 15000 764 + ); 765 + 766 + it( 767 + "Ctrl+Z suspends a process in bash", 768 + async () => { 769 + const session = await createSession("bash", [ 770 + "--norc", 771 + "--noprofile", 772 + ]); 773 + 774 + await session.waitFor( 775 + (ss) => /[$#]/.test(ss.text), 776 + 5000, 777 + "prompt" 778 + ); 779 + 780 + session.sendKeys("cat\n"); 781 + await new Promise((r) => setTimeout(r, 300)); 782 + 783 + session.sendKeys("\x1a"); // Ctrl+Z = SIGTSTP 784 + 785 + // bash should show stopped message and give us a prompt back 786 + const ss = await session.waitFor( 787 + (s) => 788 + (s.text.includes("Stopped") || s.text.includes("suspended")) && 789 + // prompt should reappear after the stopped message 790 + /[$#]\s*$/.test(s.lines[s.lines.length - 1] ?? ""), 791 + 5000, 792 + "stopped message and prompt" 793 + ); 794 + 795 + expect(ss.text).toMatch(/[Ss]topped|suspended/); 796 + 797 + session.sendKeys("exit\n"); 798 + // bash may warn about stopped jobs — force exit 799 + await new Promise((r) => setTimeout(r, 200)); 800 + session.sendKeys("exit\n"); 801 + }, 802 + 15000 803 + ); 804 + }); 805 + 806 + // ─── Tests: terminal resize ─── 807 + 808 + describe("screenshot: terminal resize", () => { 809 + it( 810 + "vim redraws after resize", 811 + async () => { 812 + const dir = makeTmpDir(); 813 + const filePath = path.join(dir, "resize.txt"); 814 + fs.writeFileSync(filePath, "Line one\nLine two\nLine three\n"); 815 + 816 + const session = await createSession( 817 + "vim", 818 + ["--clean", filePath], 819 + { rows: 24, cols: 80 } 820 + ); 821 + 822 + await session.waitForText("Line one", 10000); 823 + 824 + // Resize to narrower terminal 825 + session.resize(24, 40); 826 + await new Promise((r) => setTimeout(r, 500)); 827 + 828 + // Content should still be visible after resize 829 + const ss = session.screenshot(); 830 + expect(ss.text).toContain("Line one"); 831 + expect(ss.text).toContain("Line two"); 832 + expect(ss.text).toContain("Line three"); 833 + 834 + session.sendKeys(":q\n"); 835 + }, 836 + 15000 837 + ); 838 + 839 + it("tput reports updated dimensions after resize", async () => { 840 + const session = await createSession("bash", [ 841 + "--norc", 842 + "--noprofile", 843 + ]); 844 + 845 + await session.waitFor( 846 + (ss) => /[$#]/.test(ss.text), 847 + 5000, 848 + "prompt" 849 + ); 850 + 851 + session.resize(30, 100); 852 + await new Promise((r) => setTimeout(r, 200)); 853 + 854 + session.sendKeys("echo \"cols=$(tput cols) rows=$(tput lines)\"\n"); 855 + const ss = await session.waitForText("cols="); 856 + expect(ss.text).toContain("cols=100"); 857 + expect(ss.text).toContain("rows=30"); 858 + 859 + session.sendKeys("exit\n"); 860 + }, 15000); 861 + }); 862 + 863 + // ─── Tests: Unicode / wide characters ─── 864 + 865 + describe("screenshot: unicode", () => { 866 + it("renders CJK characters", async () => { 867 + const session = await createSession("sh", [ 868 + "-c", 869 + "echo '日本語 中文 한국어'; sleep 30", 870 + ]); 871 + 872 + const ss = await session.waitForText("日本語"); 873 + expect(ss.text).toContain("日本語"); 874 + expect(ss.text).toContain("中文"); 875 + expect(ss.text).toContain("한국어"); 876 + }); 877 + 878 + it("renders emoji", async () => { 879 + const session = await createSession("sh", [ 880 + "-c", 881 + "echo '🎉 🚀 ✅ ❌'; sleep 30", 882 + ]); 883 + 884 + const ss = await session.waitForText("🎉"); 885 + expect(ss.text).toContain("🎉"); 886 + expect(ss.text).toContain("🚀"); 887 + }); 888 + 889 + it("unicode survives detach/reattach", async () => { 890 + const session = await createSession("sh", [ 891 + "-c", 892 + "echo '你好世界 🌍'; sleep 30", 893 + ]); 894 + 895 + await session.waitForText("你好世界"); 896 + await session.reconnect(); 897 + 898 + const ss = await session.waitForText("你好世界"); 899 + expect(ss.text).toContain("你好世界"); 900 + expect(ss.text).toContain("🌍"); 901 + }); 902 + 903 + it("mixed ASCII and wide characters on the same line", async () => { 904 + const session = await createSession("sh", [ 905 + "-c", 906 + "echo 'Hello 世界 World 🌍 End'; sleep 30", 907 + ]); 908 + 909 + const ss = await session.waitForText("Hello"); 910 + expect(ss.lines[0]).toContain("Hello 世界 World"); 911 + }); 912 + }); 913 + 914 + // ─── Tests: alternate screen buffer ─── 915 + 916 + describe("screenshot: alternate screen buffer", () => { 917 + it("main buffer is restored after alternate screen exits", async () => { 918 + const session = await createSession("sh", [ 919 + "-c", 920 + // Write to main buffer, switch to alt, write there, switch back 921 + "echo 'main-buffer-text';" + 922 + "printf '\\033[?1049h';" + 923 + "printf 'alt-screen-only';" + 924 + "sleep 0.3;" + 925 + "printf '\\033[?1049l';" + 926 + "sleep 30", 927 + ]); 928 + 929 + const ss = await session.waitForText("main-buffer-text"); 930 + expect(ss.text).toContain("main-buffer-text"); 931 + // Alt screen content should NOT appear in the main buffer 932 + expect(ss.text).not.toContain("alt-screen-only"); 933 + }); 934 + 935 + it("screen replay shows main buffer after alt screen program exits", async () => { 936 + const session = await createSession("sh", [ 937 + "-c", 938 + "echo 'before-alt-screen';" + 939 + "printf '\\033[?1049h';" + 940 + "printf 'temporary-alt';" + 941 + "sleep 0.3;" + 942 + "printf '\\033[?1049l';" + 943 + "echo 'after-alt-screen';" + 944 + "sleep 30", 945 + ]); 946 + 947 + await session.waitForText("after-alt-screen"); 948 + await session.reconnect(); 949 + 950 + const ss = await session.waitForText("after-alt-screen"); 951 + expect(ss.text).toContain("before-alt-screen"); 952 + expect(ss.text).toContain("after-alt-screen"); 953 + expect(ss.text).not.toContain("temporary-alt"); 954 + }); 955 + }); 956 + 957 + // ─── Tests: multiple clients ─── 958 + 959 + describe("screenshot: multiple clients", () => { 960 + it("two attached clients see identical screen content", async () => { 961 + const session = await createSession("sh", [ 962 + "-c", 963 + "printf '\\033[31mShared colored output\\033[0m\\n'; sleep 30", 964 + ]); 965 + 966 + await session.waitForText("Shared colored output"); 967 + 968 + const peer = await TestSession.connectToExisting( 969 + session.name, 970 + session.server 971 + ); 972 + sessions.push(peer); 973 + await peer.attach(); 974 + 975 + const ss1 = session.screenshot(); 976 + const ss2 = peer.screenshot(); 977 + 978 + expect(ss2.text).toBe(ss1.text); 979 + expect(ss2.ansi).toBe(ss1.ansi); 980 + }); 981 + 982 + it("both clients receive live output from a new command", async () => { 983 + const session = await createSession("cat"); 984 + 985 + const peer = await TestSession.connectToExisting( 986 + session.name, 987 + session.server 988 + ); 989 + sessions.push(peer); 990 + await peer.attach(); 991 + 992 + session.sendKeys("both-see-this\n"); 993 + 994 + await session.waitForText("both-see-this"); 995 + await peer.waitForText("both-see-this"); 996 + 997 + const ss1 = session.screenshot(); 998 + const ss2 = peer.screenshot(); 999 + expect(ss2.text).toContain("both-see-this"); 1000 + expect(ss1.text).toContain("both-see-this"); 1001 + }); 1002 + }); 1003 + 1004 + // ─── Tests: high-throughput output ─── 1005 + 1006 + describe("screenshot: high-throughput output", () => { 1007 + it("captures rapidly scrolling output", async () => { 1008 + const session = await createSession("sh", [ 1009 + "-c", 1010 + "seq 1 500; sleep 30", 1011 + ]); 1012 + 1013 + const ss = await session.waitForText("500"); 1014 + // Should have the last line 1015 + expect(ss.text).toContain("500"); 1016 + // And earlier lines in scrollback 1017 + expect(ss.text).toContain("1"); 1018 + }); 1019 + 1020 + it("high-throughput output survives screen replay", async () => { 1021 + const session = await createSession("sh", [ 1022 + "-c", 1023 + "seq 1 500; sleep 30", 1024 + ]); 1025 + 1026 + await session.waitForText("500"); 1027 + 1028 + await session.reconnect(); 1029 + 1030 + const ss = await session.waitForText("500"); 1031 + expect(ss.text).toContain("500"); 1032 + expect(ss.text).toContain("1"); 1033 + }); 1034 + }); 1035 + 1036 + // ─── Tests: daemon spawning ─── 1037 + 1038 + describe("daemon spawning", () => { 1039 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 1040 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 1041 + const serverModule = path.join(__dirname, "..", "src", "server.ts"); 1042 + 1043 + it( 1044 + "daemon starts and serves a session via the tsx spawn mechanism", 1045 + async () => { 1046 + const name = uniqueName(); 1047 + const config = JSON.stringify({ 1048 + name, 1049 + command: "sh", 1050 + args: ["-c", "echo 'daemon works'; sleep 30"], 1051 + cwd: testCwd, 1052 + rows: 24, 1053 + cols: 80, 1054 + }); 1055 + 1056 + const child = spawn(tsxBin, [serverModule], { 1057 + detached: true, 1058 + stdio: ["ignore", "ignore", "pipe"], 1059 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 1060 + }); 1061 + 1062 + let stderr = ""; 1063 + child.stderr?.on("data", (d: Buffer) => { 1064 + stderr += d.toString(); 1065 + }); 1066 + 1067 + let exitCode: number | null = null; 1068 + child.on("exit", (code) => { 1069 + exitCode = code; 1070 + }); 1071 + 1072 + (child.stderr as any)?.unref?.(); 1073 + child.unref(); 1074 + daemonPids.push(child.pid!); 1075 + 1076 + const socketPath = getSocketPath(name); 1077 + const start = Date.now(); 1078 + while (Date.now() - start < 5000) { 1079 + if (exitCode !== null) { 1080 + throw new Error( 1081 + `Daemon exited with code ${exitCode}. stderr:\n${stderr}` 1082 + ); 1083 + } 1084 + try { 1085 + fs.statSync(socketPath); 1086 + break; 1087 + } catch {} 1088 + await new Promise((r) => setTimeout(r, 50)); 1089 + } 1090 + 1091 + await new Promise((r) => setTimeout(r, 300)); 1092 + 1093 + const socket = await new Promise<net.Socket>((resolve, reject) => { 1094 + const s = net.createConnection(socketPath); 1095 + s.on("connect", () => resolve(s)); 1096 + s.on("error", reject); 1097 + }); 1098 + 1099 + const reader = new PacketReader(); 1100 + socket.write(encodeAttach(24, 80)); 1101 + 1102 + const screen = await new Promise<string>((resolve, reject) => { 1103 + const timer = setTimeout( 1104 + () => reject(new Error("Timed out waiting for SCREEN")), 1105 + 5000 1106 + ); 1107 + socket.on("data", (data: Buffer) => { 1108 + const packets = reader.feed(data); 1109 + for (const p of packets) { 1110 + if (p.type === MessageType.SCREEN) { 1111 + clearTimeout(timer); 1112 + resolve(p.payload.toString()); 1113 + } 1114 + } 1115 + }); 1116 + }); 1117 + 1118 + expect(screen).toContain("daemon works"); 1119 + socket.destroy(); 1120 + }, 1121 + 15000 1122 + ); 1123 + 1124 + it( 1125 + "daemon handles ls command correctly", 1126 + async () => { 1127 + const name = uniqueName(); 1128 + const dir = makeTmpDir(); 1129 + fs.writeFileSync(path.join(dir, "daemon-test.txt"), ""); 1130 + 1131 + const config = JSON.stringify({ 1132 + name, 1133 + command: "sh", 1134 + args: ["-c", `ls ${dir}; sleep 30`], 1135 + cwd: testCwd, 1136 + rows: 24, 1137 + cols: 80, 1138 + }); 1139 + 1140 + const child = spawn(tsxBin, [serverModule], { 1141 + detached: true, 1142 + stdio: ["ignore", "ignore", "pipe"], 1143 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 1144 + }); 1145 + 1146 + let stderr = ""; 1147 + child.stderr?.on("data", (d: Buffer) => { 1148 + stderr += d.toString(); 1149 + }); 1150 + 1151 + let exitCode: number | null = null; 1152 + child.on("exit", (code) => { 1153 + exitCode = code; 1154 + }); 1155 + 1156 + (child.stderr as any)?.unref?.(); 1157 + child.unref(); 1158 + daemonPids.push(child.pid!); 1159 + 1160 + const socketPath = getSocketPath(name); 1161 + const start = Date.now(); 1162 + while (Date.now() - start < 5000) { 1163 + if (exitCode !== null) { 1164 + throw new Error( 1165 + `Daemon exited with code ${exitCode}. stderr:\n${stderr}` 1166 + ); 1167 + } 1168 + try { 1169 + fs.statSync(socketPath); 1170 + break; 1171 + } catch {} 1172 + await new Promise((r) => setTimeout(r, 50)); 1173 + } 1174 + 1175 + await new Promise((r) => setTimeout(r, 500)); 1176 + 1177 + const socket = await new Promise<net.Socket>((resolve, reject) => { 1178 + const s = net.createConnection(socketPath); 1179 + s.on("connect", () => resolve(s)); 1180 + s.on("error", reject); 1181 + }); 1182 + 1183 + const reader = new PacketReader(); 1184 + socket.write(encodeAttach(24, 80)); 1185 + 1186 + const screen = await new Promise<string>((resolve, reject) => { 1187 + const timer = setTimeout( 1188 + () => reject(new Error("Timed out waiting for SCREEN")), 1189 + 5000 1190 + ); 1191 + socket.on("data", (data: Buffer) => { 1192 + const packets = reader.feed(data); 1193 + for (const p of packets) { 1194 + if (p.type === MessageType.SCREEN) { 1195 + clearTimeout(timer); 1196 + resolve(p.payload.toString()); 1197 + } 1198 + } 1199 + }); 1200 + }); 1201 + 1202 + expect(screen).toContain("daemon-test.txt"); 1203 + socket.destroy(); 1204 + }, 1205 + 15000 1206 + ); 1207 + }); 1208 + 1209 + // ─── Tests: immediate attach after daemon start (race condition investigation) ─── 1210 + 1211 + describe("immediate attach after daemon start", () => { 1212 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 1213 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 1214 + const serverModule = path.join(__dirname, "..", "src", "server.ts"); 1215 + 1216 + async function spawnDaemonAndWaitForSocket( 1217 + name: string, 1218 + command: string, 1219 + args: string[] 1220 + ): Promise<string> { 1221 + const config = JSON.stringify({ 1222 + name, 1223 + command, 1224 + args, 1225 + displayCommand: command, 1226 + cwd: testCwd, 1227 + rows: 24, 1228 + cols: 80, 1229 + }); 1230 + 1231 + const child = spawn(tsxBin, [serverModule], { 1232 + detached: true, 1233 + stdio: ["ignore", "ignore", "pipe"], 1234 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 1235 + }); 1236 + 1237 + let stderr = ""; 1238 + child.stderr?.on("data", (d: Buffer) => { 1239 + stderr += d.toString(); 1240 + }); 1241 + 1242 + let exitCode: number | null = null; 1243 + child.on("exit", (code) => { 1244 + exitCode = code; 1245 + }); 1246 + 1247 + (child.stderr as any)?.unref?.(); 1248 + child.unref(); 1249 + daemonPids.push(child.pid!); 1250 + 1251 + const socketPath = getSocketPath(name); 1252 + const start = Date.now(); 1253 + while (Date.now() - start < 5000) { 1254 + if (exitCode !== null) { 1255 + throw new Error( 1256 + `Daemon exited with code ${exitCode}. stderr:\n${stderr}` 1257 + ); 1258 + } 1259 + try { 1260 + fs.statSync(socketPath); 1261 + break; 1262 + } catch {} 1263 + await new Promise((r) => setTimeout(r, 50)); 1264 + } 1265 + 1266 + // Same 100ms delay the CLI uses after socket appears 1267 + await new Promise((r) => setTimeout(r, 100)); 1268 + return socketPath; 1269 + } 1270 + 1271 + it( 1272 + "input is received when attaching immediately after daemon start", 1273 + async () => { 1274 + const name = uniqueName(); 1275 + const socketPath = await spawnDaemonAndWaitForSocket(name, "cat", []); 1276 + 1277 + // Connect and attach immediately (mimics pty run flow) 1278 + const socket = await new Promise<net.Socket>((resolve, reject) => { 1279 + const s = net.createConnection(socketPath); 1280 + s.on("connect", () => resolve(s)); 1281 + s.on("error", reject); 1282 + }); 1283 + 1284 + const reader = new PacketReader(); 1285 + const receivedData: string[] = []; 1286 + 1287 + socket.on("data", (data: Buffer) => { 1288 + const packets = reader.feed(data); 1289 + for (const p of packets) { 1290 + if (p.type === MessageType.DATA) { 1291 + receivedData.push(p.payload.toString()); 1292 + } 1293 + } 1294 + }); 1295 + 1296 + socket.write(encodeAttach(24, 80)); 1297 + 1298 + // Wait for SCREEN packet 1299 + await new Promise<void>((resolve, reject) => { 1300 + const timer = setTimeout( 1301 + () => reject(new Error("Timed out waiting for SCREEN")), 1302 + 5000 1303 + ); 1304 + const origHandler = socket.listeners("data")[0] as (...args: any[]) => void; 1305 + const screenReader = new PacketReader(); 1306 + socket.on("data", function screenCheck(data: Buffer) { 1307 + const packets = screenReader.feed(data); 1308 + for (const p of packets) { 1309 + if (p.type === MessageType.SCREEN) { 1310 + clearTimeout(timer); 1311 + socket.removeListener("data", screenCheck); 1312 + resolve(); 1313 + return; 1314 + } 1315 + } 1316 + }); 1317 + }); 1318 + 1319 + // Send input immediately after SCREEN — this is the critical moment 1320 + socket.write(encodeData("race-test-input\n")); 1321 + 1322 + // Verify cat echoes it back 1323 + const start = Date.now(); 1324 + while (Date.now() - start < 3000) { 1325 + if (receivedData.join("").includes("race-test-input")) break; 1326 + await new Promise((r) => setTimeout(r, 50)); 1327 + } 1328 + 1329 + expect(receivedData.join("")).toContain("race-test-input"); 1330 + socket.destroy(); 1331 + }, 1332 + 15000 1333 + ); 1334 + 1335 + it( 1336 + "input works with zero delay after socket appears", 1337 + async () => { 1338 + const name = uniqueName(); 1339 + // Spawn daemon but with NO delay after socket appears 1340 + const config = JSON.stringify({ 1341 + name, 1342 + command: "cat", 1343 + args: [], 1344 + displayCommand: "cat", 1345 + cwd: testCwd, 1346 + rows: 24, 1347 + cols: 80, 1348 + }); 1349 + 1350 + const child = spawn(tsxBin, [serverModule], { 1351 + detached: true, 1352 + stdio: ["ignore", "ignore", "pipe"], 1353 + env: { ...process.env, PTY_SERVER_CONFIG: config }, 1354 + }); 1355 + 1356 + let stderr = ""; 1357 + child.stderr?.on("data", (d: Buffer) => { 1358 + stderr += d.toString(); 1359 + }); 1360 + 1361 + let exitCode: number | null = null; 1362 + child.on("exit", (code) => { 1363 + exitCode = code; 1364 + }); 1365 + 1366 + (child.stderr as any)?.unref?.(); 1367 + child.unref(); 1368 + daemonPids.push(child.pid!); 1369 + 1370 + const socketPath = getSocketPath(name); 1371 + const start = Date.now(); 1372 + while (Date.now() - start < 5000) { 1373 + if (exitCode !== null) { 1374 + throw new Error( 1375 + `Daemon exited with code ${exitCode}. stderr:\n${stderr}` 1376 + ); 1377 + } 1378 + try { 1379 + fs.statSync(socketPath); 1380 + break; 1381 + } catch {} 1382 + await new Promise((r) => setTimeout(r, 50)); 1383 + } 1384 + 1385 + // NO delay — connect immediately when socket file exists 1386 + const socket = await new Promise<net.Socket>((resolve, reject) => { 1387 + const s = net.createConnection(socketPath); 1388 + s.on("connect", () => resolve(s)); 1389 + s.on("error", reject); 1390 + }); 1391 + 1392 + const reader = new PacketReader(); 1393 + const receivedData: string[] = []; 1394 + 1395 + socket.on("data", (data: Buffer) => { 1396 + const packets = reader.feed(data); 1397 + for (const p of packets) { 1398 + if (p.type === MessageType.DATA) { 1399 + receivedData.push(p.payload.toString()); 1400 + } 1401 + } 1402 + }); 1403 + 1404 + // Attach and send input as fast as possible 1405 + socket.write(encodeAttach(24, 80)); 1406 + socket.write(encodeData("zero-delay-test\n")); 1407 + 1408 + // Verify cat echoes it back 1409 + const deadline = Date.now() + 3000; 1410 + while (Date.now() < deadline) { 1411 + if (receivedData.join("").includes("zero-delay-test")) break; 1412 + await new Promise((r) => setTimeout(r, 50)); 1413 + } 1414 + 1415 + expect(receivedData.join("")).toContain("zero-delay-test"); 1416 + socket.destroy(); 1417 + }, 1418 + 15000 1419 + ); 1420 + });
+14
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "Node16", 5 + "moduleResolution": "Node16", 6 + "strict": true, 7 + "esModuleInterop": true, 8 + "skipLibCheck": true, 9 + "noEmit": true, 10 + "allowImportingTsExtensions": true 11 + }, 12 + "include": ["src", "tests"], 13 + "exclude": ["node_modules"] 14 + }