This repository has no description
0

Configure Feed

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

Fix more edge cases, introduce send command

Nathan Herald (Mar 9, 2026, 11:48 PM +0100) c96d1f19 c019491f

+521 -5
+5
README.md
··· 24 24 pty peek myserver # print current screen and exit 25 25 pty peek -f myserver # follow output read-only 26 26 27 + pty send myserver "hello" # send text (no implicit newline) 28 + pty send myserver $'hello\n' # send text with newline (shell syntax) 29 + pty send myserver --seq "git status" --seq key:return # ordered sequence 30 + pty send myserver --seq key:ctrl+c # send control keys 31 + 27 32 pty restart myserver # restart an exited session 28 33 pty kill myserver # terminate a session 29 34 ```
+2 -2
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach peek kill list restart help" 9 + commands="run attach peek send kill list restart help" 10 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 16 16 17 17 # Complete session names for commands that take them 18 18 case "${COMP_WORDS[1]}" in 19 - attach|a|peek|kill|restart) 19 + attach|a|peek|send|kill|restart) 20 20 local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}" 21 21 if [[ -d "${session_dir}" ]]; then 22 22 local names
+6
completions/pty.zsh
··· 18 18 'run:Create a session and attach' 19 19 'attach:Attach to an existing session' 20 20 'peek:Print current screen or follow output' 21 + 'send:Send text or keys to a session' 21 22 'kill:Kill or remove a session' 22 23 'list:List active sessions' 23 24 'restart:Restart an exited session' ··· 43 44 _arguments \ 44 45 '(-f --follow)'{-f,--follow}'[Follow output read-only]' \ 45 46 '1:session:_pty_sessions' 47 + ;; 48 + send) 49 + _arguments \ 50 + '1:session:_pty_sessions' \ 51 + '*--seq[Send a sequence item]:value:' 46 52 ;; 47 53 kill|restart) 48 54 _arguments '1:session:_pty_sessions'
+53 -1
src/cli.ts
··· 5 5 import * as readline from "node:readline/promises"; 6 6 import * as tty from "node:tty"; 7 7 import { fileURLToPath } from "node:url"; 8 - import { attach, peek } from "./client.ts"; 8 + import { attach, peek, send } from "./client.ts"; 9 + import { parseSeqValue } from "./keys.ts"; 9 10 import { 10 11 listSessions, 11 12 getSession, ··· 29 30 pty attach -r <name> Attach, auto-restart if exited 30 31 pty peek <name> Print current screen and exit 31 32 pty peek -f <name> Follow output read-only (Ctrl+\\ to stop) 33 + pty send <name> "text" Send text to a session 34 + pty send <name> --seq "text" --seq key:return Send an ordered sequence 32 35 pty restart <name> Restart an exited session 33 36 pty list List active sessions 34 37 pty kill <name> Kill or remove a session ··· 128 131 process.exit(1); 129 132 } 130 133 cmdPeek(peekName, follow); 134 + break; 135 + } 136 + 137 + case "send": { 138 + const sendName = args[1]; 139 + if (!sendName) { 140 + console.error('Usage: pty send <name> "text" or pty send <name> --seq "text" --seq key:return'); 141 + process.exit(1); 142 + } 143 + try { 144 + validateName(sendName); 145 + } catch (e: any) { 146 + console.error(e.message); 147 + process.exit(1); 148 + } 149 + 150 + const sendArgs = args.slice(2); 151 + const hasSeq = sendArgs.includes("--seq"); 152 + const hasPositional = sendArgs.length > 0 && !sendArgs[0].startsWith("--"); 153 + 154 + if (hasSeq && hasPositional) { 155 + console.error("Cannot mix positional text with --seq flags."); 156 + process.exit(1); 157 + } 158 + 159 + let data: string[]; 160 + if (hasSeq) { 161 + data = []; 162 + for (let j = 0; j < sendArgs.length; j++) { 163 + if (sendArgs[j] === "--seq") { 164 + j++; 165 + if (j >= sendArgs.length) { 166 + console.error("--seq requires a value."); 167 + process.exit(1); 168 + } 169 + data.push(parseSeqValue(sendArgs[j])); 170 + } else { 171 + console.error(`Unexpected argument: ${sendArgs[j]}`); 172 + process.exit(1); 173 + } 174 + } 175 + } else if (hasPositional) { 176 + data = [sendArgs[0]]; 177 + } else { 178 + console.error("Nothing to send."); 179 + process.exit(1); 180 + } 181 + 182 + send({ name: sendName, data }); 131 183 break; 132 184 } 133 185
+31
src/client.ts
··· 138 138 }); 139 139 } 140 140 141 + export interface SendOptions { 142 + name: string; 143 + data: string[]; 144 + } 145 + 146 + /** Send data to a session without attaching. Silent on success. */ 147 + export function send(options: SendOptions): void { 148 + const socketPath = getSocketPath(options.name); 149 + const socket = net.createConnection(socketPath); 150 + 151 + socket.on("connect", () => { 152 + for (const item of options.data) { 153 + socket.write(encodeData(item)); 154 + } 155 + socket.end(); 156 + }); 157 + 158 + socket.on("error", (err: NodeJS.ErrnoException) => { 159 + if (err.code === "ENOENT" || err.code === "ECONNREFUSED") { 160 + console.error(`Session "${options.name}" not found or not running.`); 161 + } else { 162 + console.error(`Connection error: ${err.message}`); 163 + } 164 + process.exit(1); 165 + }); 166 + 167 + socket.on("close", () => { 168 + process.exit(0); 169 + }); 170 + } 171 + 141 172 export interface AttachOptions { 142 173 name: string; 143 174 onExit?: (code: number) => void;
+77
src/keys.ts
··· 1 + const KEY_MAP: Record<string, string> = { 2 + return: "\r", 3 + enter: "\r", 4 + tab: "\t", 5 + escape: "\x1b", 6 + esc: "\x1b", 7 + space: " ", 8 + backspace: "\x7f", 9 + delete: "\x1b[3~", 10 + up: "\x1b[A", 11 + down: "\x1b[B", 12 + right: "\x1b[C", 13 + left: "\x1b[D", 14 + home: "\x1b[H", 15 + end: "\x1b[F", 16 + pageup: "\x1b[5~", 17 + pagedown: "\x1b[6~", 18 + }; 19 + 20 + const MODIFIERS = new Set(["ctrl", "alt", "shift"]); 21 + 22 + /** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */ 23 + export function resolveKey(spec: string): string { 24 + const parts = spec.toLowerCase().split("+"); 25 + const base = parts.pop()!; 26 + const mods = new Set(parts); 27 + 28 + // Validate modifiers 29 + for (const mod of mods) { 30 + if (!MODIFIERS.has(mod)) { 31 + throw new Error(`Unknown modifier: "${mod}" in key spec "${spec}"`); 32 + } 33 + } 34 + 35 + let result: string; 36 + 37 + if (KEY_MAP[base] !== undefined) { 38 + result = KEY_MAP[base]; 39 + } else if (base.length === 1 && base >= "a" && base <= "z") { 40 + result = base; 41 + } else { 42 + throw new Error(`Unknown key: "${base}" in key spec "${spec}"`); 43 + } 44 + 45 + // Apply shift (only meaningful for single letters) 46 + if (mods.has("shift")) { 47 + if (result.length === 1 && result >= "a" && result <= "z") { 48 + result = result.toUpperCase(); 49 + } 50 + // shift on non-letters is silently ignored (e.g. shift+return = return) 51 + } 52 + 53 + // Apply ctrl (only meaningful for single letters) 54 + if (mods.has("ctrl")) { 55 + if (result.length === 1) { 56 + const code = result.toLowerCase().charCodeAt(0); 57 + if (code >= 97 && code <= 122) { 58 + result = String.fromCharCode(code - 96); 59 + } 60 + } 61 + } 62 + 63 + // Apply alt (prefix with ESC) 64 + if (mods.has("alt")) { 65 + result = "\x1b" + result; 66 + } 67 + 68 + return result; 69 + } 70 + 71 + /** If value starts with `key:`, resolve the key name; otherwise return the literal string. */ 72 + export function parseSeqValue(value: string): string { 73 + if (value.startsWith("key:")) { 74 + return resolveKey(value.slice(4)); 75 + } 76 + return value; 77 + }
+54 -2
src/server.ts
··· 58 58 private name: string; 59 59 private options: ServerOptions; 60 60 private attachCounter = 0; 61 + private sgrMouseMode = false; 62 + private cursorHidden = false; 63 + private kittyKeyboardStack: number[] = []; 61 64 readonly ready: Promise<void>; 62 65 63 66 constructor(options: ServerOptions) { ··· 74 77 this.serialize = new xtermSerialize.SerializeAddon(); 75 78 this.terminal.loadAddon(this.serialize); 76 79 80 + // Track terminal modes not exposed by xterm's serialize addon 81 + this.terminal.parser.registerCsiHandler( 82 + { prefix: "?", final: "h" }, 83 + (params) => { 84 + for (const p of params) { 85 + const v = typeof p === "number" ? p : p[0]; 86 + if (v === 1006) this.sgrMouseMode = true; 87 + if (v === 25) this.cursorHidden = false; 88 + } 89 + return false; 90 + } 91 + ); 92 + this.terminal.parser.registerCsiHandler( 93 + { prefix: "?", final: "l" }, 94 + (params) => { 95 + for (const p of params) { 96 + const v = typeof p === "number" ? p : p[0]; 97 + if (v === 1006) this.sgrMouseMode = false; 98 + if (v === 25) this.cursorHidden = true; 99 + } 100 + return false; 101 + } 102 + ); 103 + this.terminal.parser.registerCsiHandler( 104 + { prefix: ">", final: "u" }, 105 + (params) => { 106 + const flags = typeof params[0] === "number" ? params[0] : params[0][0]; 107 + this.kittyKeyboardStack.push(flags); 108 + return false; 109 + } 110 + ); 111 + this.terminal.parser.registerCsiHandler( 112 + { prefix: "<", final: "u" }, 113 + () => { 114 + this.kittyKeyboardStack.pop(); 115 + return false; 116 + } 117 + ); 118 + 77 119 // Spawn the child process in a PTY via a shell, so that shell scripts, 78 120 // symlinks, and shebangs all work reliably (like tmux/screen do). 79 121 // `exec "$@"` replaces the shell with the actual process. ··· 171 213 172 214 const sendScreen = () => { 173 215 if (socket.destroyed) return; 174 - const screen = this.serialize.serialize(); 216 + const screen = this.getModePrefix() + this.serialize.serialize(); 175 217 socket.write(encodeScreen(screen)); 176 218 if (this.exited) { 177 219 socket.write(encodeExit(this.exitCode)); ··· 194 236 client.readonly = true; 195 237 196 238 // Send current screen state (same as ATTACH) 197 - const peekScreen = this.serialize.serialize(); 239 + const peekScreen = this.getModePrefix() + this.serialize.serialize(); 198 240 socket.write(encodeScreen(peekScreen)); 199 241 200 242 if (this.exited) { ··· 236 278 socket.on("error", () => { 237 279 this.clients.delete(socket); 238 280 }); 281 + } 282 + 283 + private getModePrefix(): string { 284 + let prefix = ""; 285 + if (this.sgrMouseMode) prefix += "\x1b[?1006h"; 286 + if (this.cursorHidden) prefix += "\x1b[?25l"; 287 + for (const flags of this.kittyKeyboardStack) { 288 + prefix += `\x1b[>${flags}u`; 289 + } 290 + return prefix; 239 291 } 240 292 241 293 /** Resize the PTY to match the most recently attached client.
+212
tests/integration.test.ts
··· 718 718 719 719 client2.destroy(); 720 720 }, 15000); 721 + 722 + // ─── Terminal mode preservation across attach ─── 723 + // 724 + // Terminal input modes (keyboard protocol, mouse tracking, bracketed paste, 725 + // etc.) are set by the process writing escape sequences to the PTY. These 726 + // are consumed by xterm-headless (they're not visual content) and never 727 + // appear in SerializeAddon output. This means: 728 + // 729 + // 1. Late attach: mode was broadcast when zero clients were connected → lost 730 + // 2. Detach/reattach: TERMINAL_SANITIZE resets the mode, SCREEN doesn't 731 + // restore it, and the process doesn't re-send it → lost 732 + // 733 + // Each test starts a process that enables a mode, waits for it to 734 + // initialize, then verifies the mode sequence reaches a late-attaching 735 + // client. 736 + 737 + // Only modes NOT already preserved by xterm-headless SerializeAddon. 738 + // Modes like bracketed paste, mouse click/button-event/any-event tracking, 739 + // focus reporting, application cursor keys, and alternate screen buffer 740 + // are already serialized by xterm-headless — they pass without any extra work. 741 + const terminalModes = [ 742 + { 743 + name: "Kitty keyboard protocol", 744 + enable: "\x1b[>1u", 745 + printf: "\\033[>1u", 746 + why: "Shift+Enter, key disambiguation", 747 + }, 748 + { 749 + name: "SGR mouse mode", 750 + enable: "\x1b[?1006h", 751 + printf: "\\033[?1006h", 752 + why: "extended mouse coordinates (>223 cols)", 753 + }, 754 + { 755 + name: "cursor hidden", 756 + enable: "\x1b[?25l", 757 + printf: "\\033[?25l", 758 + why: "TUI apps hide cursor during rendering", 759 + }, 760 + ]; 761 + 762 + for (const mode of terminalModes) { 763 + it(`${mode.name} mode reaches client on late attach (${mode.why})`, async () => { 764 + const name = uniqueName(); 765 + await startServer(name, "sh", [ 766 + "-c", 767 + `printf '${mode.printf}'; echo 'ready'; cat`, 768 + ]); 769 + 770 + // Process has initialized — mode was broadcast to zero clients. 771 + await new Promise((r) => setTimeout(r, 300)); 772 + 773 + const client = await connect(name); 774 + const reader = new PacketReader(); 775 + const payloads: string[] = []; 776 + 777 + client.on("data", (data: Buffer) => { 778 + for (const p of reader.feed(data)) { 779 + if (p.type === MessageType.SCREEN || p.type === MessageType.DATA) { 780 + payloads.push(p.payload.toString()); 781 + } 782 + } 783 + }); 784 + 785 + client.write(encodeAttach(24, 80)); 786 + await new Promise((r) => setTimeout(r, 500)); 787 + 788 + expect(payloads.join("")).toContain(mode.enable); 789 + client.destroy(); 790 + }); 791 + } 792 + 793 + // ─── send (no ATTACH) ─── 794 + 795 + describe("send", () => { 796 + it("sends text to a running session", async () => { 797 + const name = uniqueName(); 798 + await startServer(name, "cat"); 799 + 800 + // Attach a watcher to observe output 801 + const watcher = await connect(name); 802 + const watchReader = new PacketReader(); 803 + watcher.write(encodeAttach(24, 80)); 804 + await waitForType(watcher, watchReader, MessageType.SCREEN); 805 + 806 + // Send data without ATTACH — just raw DATA packets 807 + const sender = await connect(name); 808 + sender.write(encodeData("hello from send")); 809 + sender.end(); 810 + 811 + // Watcher should see the echoed output 812 + const data = await waitForType(watcher, watchReader, MessageType.DATA, 3000); 813 + expect(data.payload.toString()).toContain("hello from send"); 814 + 815 + watcher.destroy(); 816 + }); 817 + 818 + it("sends multiple DATA packets in sequence", async () => { 819 + const name = uniqueName(); 820 + await startServer(name, "cat"); 821 + 822 + const watcher = await connect(name); 823 + const watchReader = new PacketReader(); 824 + watcher.write(encodeAttach(24, 80)); 825 + await waitForType(watcher, watchReader, MessageType.SCREEN); 826 + 827 + // Start accumulating DATA before sending 828 + let output = ""; 829 + const watchReader2 = new PacketReader(); 830 + watcher.on("data", (data: Buffer) => { 831 + for (const p of watchReader2.feed(data)) { 832 + if (p.type === MessageType.DATA) { 833 + output += p.payload.toString(); 834 + } 835 + } 836 + }); 837 + 838 + const sender = await connect(name); 839 + sender.write(encodeData("one")); 840 + sender.write(encodeData("two")); 841 + sender.write(encodeData("three\n")); 842 + sender.end(); 843 + 844 + // Wait for cat to echo all data 845 + await new Promise((r) => setTimeout(r, 500)); 846 + expect(output).toContain("one"); 847 + expect(output).toContain("two"); 848 + expect(output).toContain("three"); 849 + 850 + watcher.destroy(); 851 + }); 852 + 853 + it("does not trigger screen replay", async () => { 854 + const name = uniqueName(); 855 + await startServer(name, "sh", ["-c", "echo 'initial output'; cat"]); 856 + await new Promise((r) => setTimeout(r, 200)); 857 + 858 + // Send data without ATTACH 859 + const sender = await connect(name); 860 + const senderReader = new PacketReader(); 861 + const senderPackets: Packet[] = []; 862 + 863 + sender.on("data", (data: Buffer) => { 864 + senderPackets.push(...senderReader.feed(data)); 865 + }); 866 + 867 + sender.write(encodeData("sent text")); 868 + await new Promise((r) => setTimeout(r, 300)); 869 + sender.end(); 870 + await new Promise((r) => sender.on("close", r)); 871 + 872 + // Sender should not have received any SCREEN packet 873 + const screenPackets = senderPackets.filter((p) => p.type === MessageType.SCREEN); 874 + expect(screenPackets.length).toBe(0); 875 + }); 876 + 877 + it("connection to non-existent session produces error", async () => { 878 + const name = uniqueName(); 879 + 880 + // Try to connect — should fail with ENOENT 881 + const socket = net.createConnection(getSocketPath(name)); 882 + const error = await new Promise<NodeJS.ErrnoException>((resolve) => { 883 + socket.on("error", resolve); 884 + }); 885 + expect(error.code).toMatch(/ENOENT|ECONNREFUSED/); 886 + }); 887 + }); 888 + 889 + // Detach/reattach: the mode was active, TERMINAL_SANITIZE popped it on 890 + // detach, and the SCREEN replay on reattach doesn't restore it. The 891 + // process only sent the mode push once at startup (like Claude Code does 892 + // with Kitty keyboard protocol) and won't re-send it. 893 + 894 + for (const mode of terminalModes) { 895 + it(`${mode.name} mode survives detach/reattach`, async () => { 896 + const name = uniqueName(); 897 + await startServer(name, "sh", [ 898 + "-c", 899 + `printf '${mode.printf}'; echo 'ready'; cat`, 900 + ]); 901 + 902 + // First client attaches immediately — gets mode via DATA 903 + const client1 = await connect(name); 904 + const reader1 = new PacketReader(); 905 + client1.on("data", (data: Buffer) => reader1.feed(data)); 906 + client1.write(encodeAttach(24, 80)); 907 + await new Promise((r) => setTimeout(r, 300)); 908 + 909 + // Detach (TERMINAL_SANITIZE resets the mode on the user's terminal) 910 + client1.write(encodeDetach()); 911 + await new Promise((r) => setTimeout(r, 200)); 912 + 913 + // Reattach — the mode must be present in SCREEN or DATA 914 + const client2 = await connect(name); 915 + const reader2 = new PacketReader(); 916 + const payloads: string[] = []; 917 + 918 + client2.on("data", (data: Buffer) => { 919 + for (const p of reader2.feed(data)) { 920 + if (p.type === MessageType.SCREEN || p.type === MessageType.DATA) { 921 + payloads.push(p.payload.toString()); 922 + } 923 + } 924 + }); 925 + 926 + client2.write(encodeAttach(24, 80)); 927 + await new Promise((r) => setTimeout(r, 500)); 928 + 929 + expect(payloads.join("")).toContain(mode.enable); 930 + client2.destroy(); 931 + }); 932 + } 721 933 });
+81
tests/keys.test.ts
··· 1 + import { describe, it, expect } from "vitest"; 2 + import { resolveKey, parseSeqValue } from "../src/keys.ts"; 3 + 4 + describe("resolveKey", () => { 5 + it("resolves named keys", () => { 6 + expect(resolveKey("return")).toBe("\r"); 7 + expect(resolveKey("enter")).toBe("\r"); 8 + expect(resolveKey("tab")).toBe("\t"); 9 + expect(resolveKey("escape")).toBe("\x1b"); 10 + expect(resolveKey("esc")).toBe("\x1b"); 11 + expect(resolveKey("space")).toBe(" "); 12 + expect(resolveKey("backspace")).toBe("\x7f"); 13 + expect(resolveKey("delete")).toBe("\x1b[3~"); 14 + }); 15 + 16 + it("resolves arrow keys", () => { 17 + expect(resolveKey("up")).toBe("\x1b[A"); 18 + expect(resolveKey("down")).toBe("\x1b[B"); 19 + expect(resolveKey("right")).toBe("\x1b[C"); 20 + expect(resolveKey("left")).toBe("\x1b[D"); 21 + }); 22 + 23 + it("resolves navigation keys", () => { 24 + expect(resolveKey("home")).toBe("\x1b[H"); 25 + expect(resolveKey("end")).toBe("\x1b[F"); 26 + expect(resolveKey("pageup")).toBe("\x1b[5~"); 27 + expect(resolveKey("pagedown")).toBe("\x1b[6~"); 28 + }); 29 + 30 + it("resolves ctrl chords", () => { 31 + expect(resolveKey("ctrl+c")).toBe("\x03"); 32 + expect(resolveKey("ctrl+a")).toBe("\x01"); 33 + expect(resolveKey("ctrl+z")).toBe("\x1a"); 34 + expect(resolveKey("ctrl+d")).toBe("\x04"); 35 + }); 36 + 37 + it("resolves alt chords", () => { 38 + expect(resolveKey("alt+x")).toBe("\x1bx"); 39 + expect(resolveKey("alt+a")).toBe("\x1ba"); 40 + }); 41 + 42 + it("resolves shift chords", () => { 43 + expect(resolveKey("shift+a")).toBe("A"); 44 + expect(resolveKey("shift+z")).toBe("Z"); 45 + }); 46 + 47 + it("resolves composed modifiers", () => { 48 + expect(resolveKey("ctrl+alt+c")).toBe("\x1b\x03"); 49 + expect(resolveKey("alt+ctrl+c")).toBe("\x1b\x03"); 50 + }); 51 + 52 + it("is case insensitive", () => { 53 + expect(resolveKey("Ctrl+C")).toBe("\x03"); 54 + expect(resolveKey("RETURN")).toBe("\r"); 55 + expect(resolveKey("Alt+X")).toBe("\x1bx"); 56 + }); 57 + 58 + it("throws on unknown key", () => { 59 + expect(() => resolveKey("f99")).toThrow(/Unknown key/); 60 + expect(() => resolveKey("nonexistent")).toThrow(/Unknown key/); 61 + }); 62 + 63 + it("throws on unknown modifier", () => { 64 + expect(() => resolveKey("super+c")).toThrow(/Unknown modifier/); 65 + expect(() => resolveKey("meta+x")).toThrow(/Unknown modifier/); 66 + }); 67 + }); 68 + 69 + describe("parseSeqValue", () => { 70 + it("resolves key: prefixed values", () => { 71 + expect(parseSeqValue("key:return")).toBe("\r"); 72 + expect(parseSeqValue("key:ctrl+c")).toBe("\x03"); 73 + expect(parseSeqValue("key:tab")).toBe("\t"); 74 + }); 75 + 76 + it("passes through literal strings", () => { 77 + expect(parseSeqValue("hello")).toBe("hello"); 78 + expect(parseSeqValue("git status")).toBe("git status"); 79 + expect(parseSeqValue("")).toBe(""); 80 + }); 81 + });