···2424pty peek myserver # print current screen and exit
2525pty peek -f myserver # follow output read-only
26262727+pty send myserver "hello" # send text (no implicit newline)
2828+pty send myserver $'hello\n' # send text with newline (shell syntax)
2929+pty send myserver --seq "git status" --seq key:return # ordered sequence
3030+pty send myserver --seq key:ctrl+c # send control keys
3131+2732pty restart myserver # restart an exited session
2833pty kill myserver # terminate a session
2934```
+2-2
completions/pty.bash
···66 COMPREPLY=()
77 cur="${COMP_WORDS[COMP_CWORD]}"
88 prev="${COMP_WORDS[COMP_CWORD-1]}"
99- commands="run attach peek kill list restart help"
99+ commands="run attach peek send kill list restart help"
10101111 # Complete subcommand
1212 if [[ ${COMP_CWORD} -eq 1 ]]; then
···16161717 # Complete session names for commands that take them
1818 case "${COMP_WORDS[1]}" in
1919- attach|a|peek|kill|restart)
1919+ attach|a|peek|send|kill|restart)
2020 local session_dir="${PTY_SESSION_DIR:-${HOME}/.local/state/pty}"
2121 if [[ -d "${session_dir}" ]]; then
2222 local names
+6
completions/pty.zsh
···1818 'run:Create a session and attach'
1919 'attach:Attach to an existing session'
2020 'peek:Print current screen or follow output'
2121+ 'send:Send text or keys to a session'
2122 'kill:Kill or remove a session'
2223 'list:List active sessions'
2324 'restart:Restart an exited session'
···4344 _arguments \
4445 '(-f --follow)'{-f,--follow}'[Follow output read-only]' \
4546 '1:session:_pty_sessions'
4747+ ;;
4848+ send)
4949+ _arguments \
5050+ '1:session:_pty_sessions' \
5151+ '*--seq[Send a sequence item]:value:'
4652 ;;
4753 kill|restart)
4854 _arguments '1:session:_pty_sessions'
+53-1
src/cli.ts
···55import * as readline from "node:readline/promises";
66import * as tty from "node:tty";
77import { fileURLToPath } from "node:url";
88-import { attach, peek } from "./client.ts";
88+import { attach, peek, send } from "./client.ts";
99+import { parseSeqValue } from "./keys.ts";
910import {
1011 listSessions,
1112 getSession,
···2930 pty attach -r <name> Attach, auto-restart if exited
3031 pty peek <name> Print current screen and exit
3132 pty peek -f <name> Follow output read-only (Ctrl+\\ to stop)
3333+ pty send <name> "text" Send text to a session
3434+ pty send <name> --seq "text" --seq key:return Send an ordered sequence
3235 pty restart <name> Restart an exited session
3336 pty list List active sessions
3437 pty kill <name> Kill or remove a session
···128131 process.exit(1);
129132 }
130133 cmdPeek(peekName, follow);
134134+ break;
135135+ }
136136+137137+ case "send": {
138138+ const sendName = args[1];
139139+ if (!sendName) {
140140+ console.error('Usage: pty send <name> "text" or pty send <name> --seq "text" --seq key:return');
141141+ process.exit(1);
142142+ }
143143+ try {
144144+ validateName(sendName);
145145+ } catch (e: any) {
146146+ console.error(e.message);
147147+ process.exit(1);
148148+ }
149149+150150+ const sendArgs = args.slice(2);
151151+ const hasSeq = sendArgs.includes("--seq");
152152+ const hasPositional = sendArgs.length > 0 && !sendArgs[0].startsWith("--");
153153+154154+ if (hasSeq && hasPositional) {
155155+ console.error("Cannot mix positional text with --seq flags.");
156156+ process.exit(1);
157157+ }
158158+159159+ let data: string[];
160160+ if (hasSeq) {
161161+ data = [];
162162+ for (let j = 0; j < sendArgs.length; j++) {
163163+ if (sendArgs[j] === "--seq") {
164164+ j++;
165165+ if (j >= sendArgs.length) {
166166+ console.error("--seq requires a value.");
167167+ process.exit(1);
168168+ }
169169+ data.push(parseSeqValue(sendArgs[j]));
170170+ } else {
171171+ console.error(`Unexpected argument: ${sendArgs[j]}`);
172172+ process.exit(1);
173173+ }
174174+ }
175175+ } else if (hasPositional) {
176176+ data = [sendArgs[0]];
177177+ } else {
178178+ console.error("Nothing to send.");
179179+ process.exit(1);
180180+ }
181181+182182+ send({ name: sendName, data });
131183 break;
132184 }
133185
+31
src/client.ts
···138138 });
139139}
140140141141+export interface SendOptions {
142142+ name: string;
143143+ data: string[];
144144+}
145145+146146+/** Send data to a session without attaching. Silent on success. */
147147+export function send(options: SendOptions): void {
148148+ const socketPath = getSocketPath(options.name);
149149+ const socket = net.createConnection(socketPath);
150150+151151+ socket.on("connect", () => {
152152+ for (const item of options.data) {
153153+ socket.write(encodeData(item));
154154+ }
155155+ socket.end();
156156+ });
157157+158158+ socket.on("error", (err: NodeJS.ErrnoException) => {
159159+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
160160+ console.error(`Session "${options.name}" not found or not running.`);
161161+ } else {
162162+ console.error(`Connection error: ${err.message}`);
163163+ }
164164+ process.exit(1);
165165+ });
166166+167167+ socket.on("close", () => {
168168+ process.exit(0);
169169+ });
170170+}
171171+141172export interface AttachOptions {
142173 name: string;
143174 onExit?: (code: number) => void;
+77
src/keys.ts
···11+const KEY_MAP: Record<string, string> = {
22+ return: "\r",
33+ enter: "\r",
44+ tab: "\t",
55+ escape: "\x1b",
66+ esc: "\x1b",
77+ space: " ",
88+ backspace: "\x7f",
99+ delete: "\x1b[3~",
1010+ up: "\x1b[A",
1111+ down: "\x1b[B",
1212+ right: "\x1b[C",
1313+ left: "\x1b[D",
1414+ home: "\x1b[H",
1515+ end: "\x1b[F",
1616+ pageup: "\x1b[5~",
1717+ pagedown: "\x1b[6~",
1818+};
1919+2020+const MODIFIERS = new Set(["ctrl", "alt", "shift"]);
2121+2222+/** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */
2323+export function resolveKey(spec: string): string {
2424+ const parts = spec.toLowerCase().split("+");
2525+ const base = parts.pop()!;
2626+ const mods = new Set(parts);
2727+2828+ // Validate modifiers
2929+ for (const mod of mods) {
3030+ if (!MODIFIERS.has(mod)) {
3131+ throw new Error(`Unknown modifier: "${mod}" in key spec "${spec}"`);
3232+ }
3333+ }
3434+3535+ let result: string;
3636+3737+ if (KEY_MAP[base] !== undefined) {
3838+ result = KEY_MAP[base];
3939+ } else if (base.length === 1 && base >= "a" && base <= "z") {
4040+ result = base;
4141+ } else {
4242+ throw new Error(`Unknown key: "${base}" in key spec "${spec}"`);
4343+ }
4444+4545+ // Apply shift (only meaningful for single letters)
4646+ if (mods.has("shift")) {
4747+ if (result.length === 1 && result >= "a" && result <= "z") {
4848+ result = result.toUpperCase();
4949+ }
5050+ // shift on non-letters is silently ignored (e.g. shift+return = return)
5151+ }
5252+5353+ // Apply ctrl (only meaningful for single letters)
5454+ if (mods.has("ctrl")) {
5555+ if (result.length === 1) {
5656+ const code = result.toLowerCase().charCodeAt(0);
5757+ if (code >= 97 && code <= 122) {
5858+ result = String.fromCharCode(code - 96);
5959+ }
6060+ }
6161+ }
6262+6363+ // Apply alt (prefix with ESC)
6464+ if (mods.has("alt")) {
6565+ result = "\x1b" + result;
6666+ }
6767+6868+ return result;
6969+}
7070+7171+/** If value starts with `key:`, resolve the key name; otherwise return the literal string. */
7272+export function parseSeqValue(value: string): string {
7373+ if (value.startsWith("key:")) {
7474+ return resolveKey(value.slice(4));
7575+ }
7676+ return value;
7777+}
+54-2
src/server.ts
···5858 private name: string;
5959 private options: ServerOptions;
6060 private attachCounter = 0;
6161+ private sgrMouseMode = false;
6262+ private cursorHidden = false;
6363+ private kittyKeyboardStack: number[] = [];
6164 readonly ready: Promise<void>;
62656366 constructor(options: ServerOptions) {
···7477 this.serialize = new xtermSerialize.SerializeAddon();
7578 this.terminal.loadAddon(this.serialize);
76798080+ // Track terminal modes not exposed by xterm's serialize addon
8181+ this.terminal.parser.registerCsiHandler(
8282+ { prefix: "?", final: "h" },
8383+ (params) => {
8484+ for (const p of params) {
8585+ const v = typeof p === "number" ? p : p[0];
8686+ if (v === 1006) this.sgrMouseMode = true;
8787+ if (v === 25) this.cursorHidden = false;
8888+ }
8989+ return false;
9090+ }
9191+ );
9292+ this.terminal.parser.registerCsiHandler(
9393+ { prefix: "?", final: "l" },
9494+ (params) => {
9595+ for (const p of params) {
9696+ const v = typeof p === "number" ? p : p[0];
9797+ if (v === 1006) this.sgrMouseMode = false;
9898+ if (v === 25) this.cursorHidden = true;
9999+ }
100100+ return false;
101101+ }
102102+ );
103103+ this.terminal.parser.registerCsiHandler(
104104+ { prefix: ">", final: "u" },
105105+ (params) => {
106106+ const flags = typeof params[0] === "number" ? params[0] : params[0][0];
107107+ this.kittyKeyboardStack.push(flags);
108108+ return false;
109109+ }
110110+ );
111111+ this.terminal.parser.registerCsiHandler(
112112+ { prefix: "<", final: "u" },
113113+ () => {
114114+ this.kittyKeyboardStack.pop();
115115+ return false;
116116+ }
117117+ );
118118+77119 // Spawn the child process in a PTY via a shell, so that shell scripts,
78120 // symlinks, and shebangs all work reliably (like tmux/screen do).
79121 // `exec "$@"` replaces the shell with the actual process.
···171213172214 const sendScreen = () => {
173215 if (socket.destroyed) return;
174174- const screen = this.serialize.serialize();
216216+ const screen = this.getModePrefix() + this.serialize.serialize();
175217 socket.write(encodeScreen(screen));
176218 if (this.exited) {
177219 socket.write(encodeExit(this.exitCode));
···194236 client.readonly = true;
195237196238 // Send current screen state (same as ATTACH)
197197- const peekScreen = this.serialize.serialize();
239239+ const peekScreen = this.getModePrefix() + this.serialize.serialize();
198240 socket.write(encodeScreen(peekScreen));
199241200242 if (this.exited) {
···236278 socket.on("error", () => {
237279 this.clients.delete(socket);
238280 });
281281+ }
282282+283283+ private getModePrefix(): string {
284284+ let prefix = "";
285285+ if (this.sgrMouseMode) prefix += "\x1b[?1006h";
286286+ if (this.cursorHidden) prefix += "\x1b[?25l";
287287+ for (const flags of this.kittyKeyboardStack) {
288288+ prefix += `\x1b[>${flags}u`;
289289+ }
290290+ return prefix;
239291 }
240292241293 /** Resize the PTY to match the most recently attached client.
+212
tests/integration.test.ts
···718718719719 client2.destroy();
720720 }, 15000);
721721+722722+ // ─── Terminal mode preservation across attach ───
723723+ //
724724+ // Terminal input modes (keyboard protocol, mouse tracking, bracketed paste,
725725+ // etc.) are set by the process writing escape sequences to the PTY. These
726726+ // are consumed by xterm-headless (they're not visual content) and never
727727+ // appear in SerializeAddon output. This means:
728728+ //
729729+ // 1. Late attach: mode was broadcast when zero clients were connected → lost
730730+ // 2. Detach/reattach: TERMINAL_SANITIZE resets the mode, SCREEN doesn't
731731+ // restore it, and the process doesn't re-send it → lost
732732+ //
733733+ // Each test starts a process that enables a mode, waits for it to
734734+ // initialize, then verifies the mode sequence reaches a late-attaching
735735+ // client.
736736+737737+ // Only modes NOT already preserved by xterm-headless SerializeAddon.
738738+ // Modes like bracketed paste, mouse click/button-event/any-event tracking,
739739+ // focus reporting, application cursor keys, and alternate screen buffer
740740+ // are already serialized by xterm-headless — they pass without any extra work.
741741+ const terminalModes = [
742742+ {
743743+ name: "Kitty keyboard protocol",
744744+ enable: "\x1b[>1u",
745745+ printf: "\\033[>1u",
746746+ why: "Shift+Enter, key disambiguation",
747747+ },
748748+ {
749749+ name: "SGR mouse mode",
750750+ enable: "\x1b[?1006h",
751751+ printf: "\\033[?1006h",
752752+ why: "extended mouse coordinates (>223 cols)",
753753+ },
754754+ {
755755+ name: "cursor hidden",
756756+ enable: "\x1b[?25l",
757757+ printf: "\\033[?25l",
758758+ why: "TUI apps hide cursor during rendering",
759759+ },
760760+ ];
761761+762762+ for (const mode of terminalModes) {
763763+ it(`${mode.name} mode reaches client on late attach (${mode.why})`, async () => {
764764+ const name = uniqueName();
765765+ await startServer(name, "sh", [
766766+ "-c",
767767+ `printf '${mode.printf}'; echo 'ready'; cat`,
768768+ ]);
769769+770770+ // Process has initialized — mode was broadcast to zero clients.
771771+ await new Promise((r) => setTimeout(r, 300));
772772+773773+ const client = await connect(name);
774774+ const reader = new PacketReader();
775775+ const payloads: string[] = [];
776776+777777+ client.on("data", (data: Buffer) => {
778778+ for (const p of reader.feed(data)) {
779779+ if (p.type === MessageType.SCREEN || p.type === MessageType.DATA) {
780780+ payloads.push(p.payload.toString());
781781+ }
782782+ }
783783+ });
784784+785785+ client.write(encodeAttach(24, 80));
786786+ await new Promise((r) => setTimeout(r, 500));
787787+788788+ expect(payloads.join("")).toContain(mode.enable);
789789+ client.destroy();
790790+ });
791791+ }
792792+793793+ // ─── send (no ATTACH) ───
794794+795795+ describe("send", () => {
796796+ it("sends text to a running session", async () => {
797797+ const name = uniqueName();
798798+ await startServer(name, "cat");
799799+800800+ // Attach a watcher to observe output
801801+ const watcher = await connect(name);
802802+ const watchReader = new PacketReader();
803803+ watcher.write(encodeAttach(24, 80));
804804+ await waitForType(watcher, watchReader, MessageType.SCREEN);
805805+806806+ // Send data without ATTACH — just raw DATA packets
807807+ const sender = await connect(name);
808808+ sender.write(encodeData("hello from send"));
809809+ sender.end();
810810+811811+ // Watcher should see the echoed output
812812+ const data = await waitForType(watcher, watchReader, MessageType.DATA, 3000);
813813+ expect(data.payload.toString()).toContain("hello from send");
814814+815815+ watcher.destroy();
816816+ });
817817+818818+ it("sends multiple DATA packets in sequence", async () => {
819819+ const name = uniqueName();
820820+ await startServer(name, "cat");
821821+822822+ const watcher = await connect(name);
823823+ const watchReader = new PacketReader();
824824+ watcher.write(encodeAttach(24, 80));
825825+ await waitForType(watcher, watchReader, MessageType.SCREEN);
826826+827827+ // Start accumulating DATA before sending
828828+ let output = "";
829829+ const watchReader2 = new PacketReader();
830830+ watcher.on("data", (data: Buffer) => {
831831+ for (const p of watchReader2.feed(data)) {
832832+ if (p.type === MessageType.DATA) {
833833+ output += p.payload.toString();
834834+ }
835835+ }
836836+ });
837837+838838+ const sender = await connect(name);
839839+ sender.write(encodeData("one"));
840840+ sender.write(encodeData("two"));
841841+ sender.write(encodeData("three\n"));
842842+ sender.end();
843843+844844+ // Wait for cat to echo all data
845845+ await new Promise((r) => setTimeout(r, 500));
846846+ expect(output).toContain("one");
847847+ expect(output).toContain("two");
848848+ expect(output).toContain("three");
849849+850850+ watcher.destroy();
851851+ });
852852+853853+ it("does not trigger screen replay", async () => {
854854+ const name = uniqueName();
855855+ await startServer(name, "sh", ["-c", "echo 'initial output'; cat"]);
856856+ await new Promise((r) => setTimeout(r, 200));
857857+858858+ // Send data without ATTACH
859859+ const sender = await connect(name);
860860+ const senderReader = new PacketReader();
861861+ const senderPackets: Packet[] = [];
862862+863863+ sender.on("data", (data: Buffer) => {
864864+ senderPackets.push(...senderReader.feed(data));
865865+ });
866866+867867+ sender.write(encodeData("sent text"));
868868+ await new Promise((r) => setTimeout(r, 300));
869869+ sender.end();
870870+ await new Promise((r) => sender.on("close", r));
871871+872872+ // Sender should not have received any SCREEN packet
873873+ const screenPackets = senderPackets.filter((p) => p.type === MessageType.SCREEN);
874874+ expect(screenPackets.length).toBe(0);
875875+ });
876876+877877+ it("connection to non-existent session produces error", async () => {
878878+ const name = uniqueName();
879879+880880+ // Try to connect — should fail with ENOENT
881881+ const socket = net.createConnection(getSocketPath(name));
882882+ const error = await new Promise<NodeJS.ErrnoException>((resolve) => {
883883+ socket.on("error", resolve);
884884+ });
885885+ expect(error.code).toMatch(/ENOENT|ECONNREFUSED/);
886886+ });
887887+ });
888888+889889+ // Detach/reattach: the mode was active, TERMINAL_SANITIZE popped it on
890890+ // detach, and the SCREEN replay on reattach doesn't restore it. The
891891+ // process only sent the mode push once at startup (like Claude Code does
892892+ // with Kitty keyboard protocol) and won't re-send it.
893893+894894+ for (const mode of terminalModes) {
895895+ it(`${mode.name} mode survives detach/reattach`, async () => {
896896+ const name = uniqueName();
897897+ await startServer(name, "sh", [
898898+ "-c",
899899+ `printf '${mode.printf}'; echo 'ready'; cat`,
900900+ ]);
901901+902902+ // First client attaches immediately — gets mode via DATA
903903+ const client1 = await connect(name);
904904+ const reader1 = new PacketReader();
905905+ client1.on("data", (data: Buffer) => reader1.feed(data));
906906+ client1.write(encodeAttach(24, 80));
907907+ await new Promise((r) => setTimeout(r, 300));
908908+909909+ // Detach (TERMINAL_SANITIZE resets the mode on the user's terminal)
910910+ client1.write(encodeDetach());
911911+ await new Promise((r) => setTimeout(r, 200));
912912+913913+ // Reattach — the mode must be present in SCREEN or DATA
914914+ const client2 = await connect(name);
915915+ const reader2 = new PacketReader();
916916+ const payloads: string[] = [];
917917+918918+ client2.on("data", (data: Buffer) => {
919919+ for (const p of reader2.feed(data)) {
920920+ if (p.type === MessageType.SCREEN || p.type === MessageType.DATA) {
921921+ payloads.push(p.payload.toString());
922922+ }
923923+ }
924924+ });
925925+926926+ client2.write(encodeAttach(24, 80));
927927+ await new Promise((r) => setTimeout(r, 500));
928928+929929+ expect(payloads.join("")).toContain(mode.enable);
930930+ client2.destroy();
931931+ });
932932+ }
721933});