···2233## Unreleased
4455+### Send
66+- Add `pty send --paste` (and `paste: true` on `SendOptions` / `SendDataOptions`) to wrap the entire payload in bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI treats everything between the markers as one paste event rather than a sequence of keystrokes — intended for injecting multi-line prompts into agent sessions (claude, aider, etc.) without premature submission when the payload contains newlines. Works with positional text, `--seq` sequences, and composes with `--with-delay`. Flag position-independent.
77+58### Client API
69- Add `env?: Record<string, string>` to `SpawnDaemonOptions` and `ServerOptions` — use this to spawn a session child with a verbatim environment (no inheritance from the daemon's `process.env`, no allow-list). `PTY_SESSION` is always injected on top so nesting detection and `pty exec` keep working. Mutually exclusive with `isolateEnv` / `extraEnv` (throws at daemon startup if both are passed). Requested by the pty-layout dev to spawn a launcher shell with a shim `tmux` on `PATH`, a custom `TMUX` marker, and a filter-tag env var.
710- Add `PtyHandle.readWrappedFlags(scrollOffset?): boolean[]` — per-row flags aligned with `readCells`, where `true` means the row continues the previous row because xterm wrapped a long line (not because the child emitted `\n`). Intended for consumers reconstructing logical lines from a visually-multi-row text selection (e.g., copying a wrapped URL to the clipboard without a spurious newline). Passes through xterm.js's `IBufferLine.isWrapped`; works for both `createPty` embedded terminals and `attachPty` remote sessions since the serialize/replay path preserves wrap state.
+1
README.md
···8484pty send myserver $'hello\n' # send text with newline (shell syntax)
8585pty send myserver --seq "git status" --seq key:return # ordered sequence
8686pty send myserver --seq key:ctrl+c # send control keys
8787+pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste
87888889pty stats # live metrics for all sessions
8990pty stats myserver # stats for a specific session
+14-1
src/cli.ts
···7575 pty send <name> "text" Send text to a session
7676 pty send <name> --seq "text" --seq key:return Send an ordered sequence
7777 pty send <name> --with-delay 0.5 --seq ... Delay between each --seq item
7878+ pty send <name> --paste "<big text>" Wrap payload in bracketed-paste markers
7879 pty restart <name> Restart a session (prompts if running)
7980 pty restart -y <name> Restart without confirmation
8081 pty events <name> Follow events from a session
···410411 }
411412412413 let sendArgs = args.slice(2);
414414+ // --paste can appear anywhere; pull it out before the rest of the
415415+ // parsing so its position relative to --seq / text doesn't matter.
416416+ let paste = false;
417417+ sendArgs = sendArgs.filter((a) => {
418418+ if (a === "--paste") { paste = true; return false; }
419419+ return true;
420420+ });
413421 let delaySecs: number | undefined;
414422 if (sendArgs[0] === "--with-delay") {
415423 sendArgs = sendArgs.slice(1);
···454462 }
455463456464 const resolvedSendName = await resolveRef(sendName);
457457- send({ name: resolvedSendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined });
465465+ send({
466466+ name: resolvedSendName,
467467+ data,
468468+ delayMs: delaySecs != null ? delaySecs * 1000 : undefined,
469469+ ...(paste ? { paste: true } : {}),
470470+ });
458471 break;
459472 }
460473
+15
src/client.ts
···1313} from "./protocol.ts";
1414import { getSocketPath } from "./sessions.ts";
1515import { stripAnsi } from "./tui/colors.ts";
1616+import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts";
16171718const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding)
1819const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol)
···160161 name: string;
161162 data: string[];
162163 delayMs?: number;
164164+ /** Wrap the entire payload (all `data` entries taken together) in
165165+ * bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI
166166+ * treats everything between the markers as one paste event rather
167167+ * than a sequence of keystrokes — useful for injecting multi-line
168168+ * prompts into agent sessions without premature submission. Receiver
169169+ * must have bracketed paste enabled (DECSET 2004); most modern
170170+ * shells and TUIs do by default. */
171171+ paste?: boolean;
163172}
164173165174/** Send data to a session without attaching. Silent on success. */
···168177 const socket = net.createConnection(socketPath);
169178170179 socket.on("connect", async () => {
180180+ if (options.paste && options.data.length > 0) {
181181+ socket.write(encodeData(BRACKETED_PASTE_START));
182182+ }
171183 for (let i = 0; i < options.data.length; i++) {
172184 if (i > 0 && options.delayMs) {
173185 await new Promise((resolve) => setTimeout(resolve, options.delayMs));
174186 }
175187 socket.write(encodeData(options.data[i]));
188188+ }
189189+ if (options.paste && options.data.length > 0) {
190190+ socket.write(encodeData(BRACKETED_PASTE_END));
176191 }
177192 socket.end();
178193 });
+15
src/connection.ts
···1212} from "./protocol.ts";
1313import { getSocketPath } from "./sessions.ts";
1414import { resolveKey } from "./keys.ts";
1515+import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts";
15161617export interface SessionConnectionOptions {
1718 name: string;
···2324 name: string;
2425 data: string[];
2526 delayMs?: number;
2727+ /** Wrap the entire payload (all `data` entries taken together) in
2828+ * bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI
2929+ * treats everything between the markers as one paste event rather
3030+ * than a sequence of keystrokes — useful for injecting multi-line
3131+ * prompts into agent sessions without premature submission. Receiver
3232+ * must have bracketed paste enabled (DECSET 2004); most modern
3333+ * shells and TUIs do by default. */
3434+ paste?: boolean;
2635}
27362837export interface PeekScreenOptions {
···157166 const socket = net.createConnection(socketPath);
158167159168 socket.on("connect", async () => {
169169+ if (options.paste && options.data.length > 0) {
170170+ socket.write(encodeData(BRACKETED_PASTE_START));
171171+ }
160172 for (let i = 0; i < options.data.length; i++) {
161173 if (i > 0 && options.delayMs) {
162174 await new Promise((r) => setTimeout(r, options.delayMs));
163175 }
164176 socket.write(encodeData(options.data[i]));
177177+ }
178178+ if (options.paste && options.data.length > 0) {
179179+ socket.write(encodeData(BRACKETED_PASTE_END));
165180 }
166181 socket.end();
167182 });
+16
src/paste.ts
···11+// Bracketed paste (DECSET 2004) helpers.
22+//
33+// When a receiving terminal has bracketed paste mode enabled, pasted
44+// text is wrapped in these markers so applications can distinguish
55+// "user typed this" from "user pasted this". Shells suppress
66+// history/autocomplete during paste, vim enters paste mode, and TUI
77+// agents (claude, aider) treat the block as one input event rather
88+// than a sequence of keystrokes.
99+//
1010+// `send --paste` wraps the entire payload in START…END so multi-line
1111+// prompts injected into agent sessions don't get submitted partway.
1212+1313+/** Sent BEFORE pasted content. CSI 200 ~. */
1414+export const BRACKETED_PASTE_START = "\x1b[200~";
1515+/** Sent AFTER pasted content. CSI 201 ~. */
1616+export const BRACKETED_PASTE_END = "\x1b[201~";
+88-1
tests/connection.test.ts
···208208 const dir = makeSessionDir();
209209 const name = uniqueName();
210210 process.env.PTY_SESSION_DIR = dir;
211211- await startDaemon(dir, name, "cat");
211211+ // `stty raw -echo` lets the ESC markers appear verbatim in the
212212+ // buffer instead of being munged to `^[` by ECHOCTL.
213213+ await startDaemon(dir, name, "sh", ["-c", "stty raw -echo; cat"]);
214214+ await new Promise((r) => setTimeout(r, 150));
212215213216 await sendData({ name, data: ["hello-send"] });
214217···225228 await expect(
226229 sendData({ name: "nonexistent", data: ["test"] })
227230 ).rejects.toThrow("not found or not running");
231231+ }, 15000);
232232+233233+ // xterm-headless parses bracketed-paste START/END as valid CSI
234234+ // sequences and absorbs them — they don't appear in the rendered
235235+ // buffer. To verify the raw bytes that reach the child, we use a
236236+ // child that dumps its stdin to a file and then read the file.
237237+ // `stty raw -echo` keeps line discipline from munging control bytes.
238238+ async function startDumper(dir: string, name: string, dumpFile: string) {
239239+ await startDaemon(dir, name, "sh", [
240240+ "-c",
241241+ `stty raw -echo; cat > ${JSON.stringify(dumpFile)}`,
242242+ ]);
243243+ await new Promise((r) => setTimeout(r, 150));
244244+ }
245245+246246+ async function waitForDump(dumpFile: string, minBytes: number, timeoutMs: number): Promise<Buffer> {
247247+ const start = Date.now();
248248+ while (Date.now() - start < timeoutMs) {
249249+ try {
250250+ const buf = fs.readFileSync(dumpFile);
251251+ if (buf.length >= minBytes) return buf;
252252+ } catch {}
253253+ await new Promise((r) => setTimeout(r, 50));
254254+ }
255255+ return fs.existsSync(dumpFile) ? fs.readFileSync(dumpFile) : Buffer.alloc(0);
256256+ }
257257+258258+ it("paste:true wraps the payload in bracketed-paste markers", async () => {
259259+ const dir = makeSessionDir();
260260+ const name = uniqueName();
261261+ process.env.PTY_SESSION_DIR = dir;
262262+ const dump = path.join(dir, "dump.bin");
263263+ await startDumper(dir, name, dump);
264264+265265+ await sendData({ name, data: ["hello-paste"], paste: true });
266266+ const received = await waitForDump(dump, "hello-paste".length + 12, 3000);
267267+ const text = received.toString("utf-8");
268268+ expect(text).toBe("\x1b[200~hello-paste\x1b[201~");
269269+ }, 15000);
270270+271271+ it("paste:true wraps a multi-item payload as one bracket pair (not per item)", async () => {
272272+ const dir = makeSessionDir();
273273+ const name = uniqueName();
274274+ process.env.PTY_SESSION_DIR = dir;
275275+ const dump = path.join(dir, "dump.bin");
276276+ await startDumper(dir, name, dump);
277277+278278+ await sendData({ name, data: ["line1\n", "line2\n", "line3"], paste: true });
279279+ const expected = "\x1b[200~line1\nline2\nline3\x1b[201~";
280280+ const received = await waitForDump(dump, expected.length, 3000);
281281+ const text = received.toString("utf-8");
282282+ expect((text.match(/\x1b\[200~/g) ?? []).length).toBe(1);
283283+ expect((text.match(/\x1b\[201~/g) ?? []).length).toBe(1);
284284+ expect(text).toBe(expected);
285285+ }, 15000);
286286+287287+ it("paste:false (default) does not add bracketed-paste markers", async () => {
288288+ const dir = makeSessionDir();
289289+ const name = uniqueName();
290290+ process.env.PTY_SESSION_DIR = dir;
291291+ const dump = path.join(dir, "dump.bin");
292292+ await startDumper(dir, name, dump);
293293+294294+ await sendData({ name, data: ["no-paste"] });
295295+ const received = await waitForDump(dump, "no-paste".length, 3000);
296296+ expect(received.toString("utf-8")).toBe("no-paste");
297297+ expect(received.toString("utf-8")).not.toContain("\x1b[200~");
298298+ expect(received.toString("utf-8")).not.toContain("\x1b[201~");
299299+ }, 15000);
300300+301301+ it("paste:true on an empty data array does not emit markers alone", async () => {
302302+ const dir = makeSessionDir();
303303+ const name = uniqueName();
304304+ process.env.PTY_SESSION_DIR = dir;
305305+ const dump = path.join(dir, "dump.bin");
306306+ await startDumper(dir, name, dump);
307307+308308+ // No content → no markers. (Sending nothing shouldn't write a bare
309309+ // START/END pair to the session.)
310310+ await sendData({ name, data: [], paste: true });
311311+ await new Promise((r) => setTimeout(r, 300));
312312+313313+ const received = fs.existsSync(dump) ? fs.readFileSync(dump) : Buffer.alloc(0);
314314+ expect(received.length).toBe(0);
228315 }, 15000);
229316});
230317
+219
tests/send-paste.test.ts
···11+// CLI-surface tests for `pty send --paste`. Exercises the binary via
22+// spawnSync so the argv-parsing path is covered end-to-end, including
33+// the `--paste` flag's position-independence (before/after --seq, with
44+// --with-delay, etc.).
55+66+import { describe, it, expect, afterEach, afterAll } from "vitest";
77+import * as fs from "node:fs";
88+import * as os from "node:os";
99+import * as path from "node:path";
1010+import { fileURLToPath } from "node:url";
1111+import { spawn, spawnSync } from "node:child_process";
1212+1313+const __dirname = path.dirname(fileURLToPath(import.meta.url));
1414+const nodeBin = process.execPath;
1515+const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1616+const serverModule = path.join(__dirname, "..", "dist", "server.js");
1717+1818+const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-paste-"));
1919+afterAll(() => {
2020+ fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
2121+});
2222+2323+let bgPids: number[] = [];
2424+let sessionDirs: string[] = [];
2525+2626+function makeSessionDir(): string {
2727+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
2828+ sessionDirs.push(dir);
2929+ return dir;
3030+}
3131+3232+let nameCounter = 0;
3333+function uniqueName(): string {
3434+ return `sp${++nameCounter}${Math.random().toString(36).slice(2, 5)}`;
3535+}
3636+3737+async function startDaemon(
3838+ sessionDir: string,
3939+ name: string,
4040+ command: string,
4141+ args: string[] = [],
4242+): Promise<number> {
4343+ const config = JSON.stringify({
4444+ name, command, args, displayCommand: command,
4545+ cwd: os.tmpdir(), rows: 24, cols: 80,
4646+ });
4747+ const child = spawn(nodeBin, [serverModule], {
4848+ detached: true,
4949+ stdio: ["ignore", "ignore", "pipe"],
5050+ env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
5151+ });
5252+ let stderr = "";
5353+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
5454+ let exitCode: number | null = null;
5555+ child.on("exit", (code) => { exitCode = code; });
5656+ (child.stderr as any)?.unref?.();
5757+ child.unref();
5858+5959+ const socketPath = path.join(sessionDir, `${name}.sock`);
6060+ const start = Date.now();
6161+ while (Date.now() - start < 5000) {
6262+ if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
6363+ try {
6464+ fs.statSync(socketPath);
6565+ await new Promise((r) => setTimeout(r, 100));
6666+ bgPids.push(child.pid!);
6767+ return child.pid!;
6868+ } catch {}
6969+ await new Promise((r) => setTimeout(r, 50));
7070+ }
7171+ throw new Error(`Timeout waiting for daemon`);
7272+}
7373+7474+function runCli(sessionDir: string, ...args: string[]) {
7575+ return spawnSync(nodeBin, [cliPath, ...args], {
7676+ cwd: os.tmpdir(),
7777+ env: { ...process.env, PTY_SESSION_DIR: sessionDir },
7878+ encoding: "utf-8",
7979+ timeout: 10_000,
8080+ });
8181+}
8282+8383+/** Run a dump-to-file child in the session so we can read the raw bytes
8484+ * the `send` write produced — xterm-headless interprets bracketed-paste
8585+ * START/END as valid CSI sequences and absorbs them, so peeking the
8686+ * screen wouldn't show the markers. `stty raw -echo` keeps the PTY line
8787+ * discipline from munging or echoing the ESC bytes. */
8888+async function startDumpSession(sessionDir: string, name: string, dumpFile: string) {
8989+ await startDaemon(sessionDir, name, "sh", [
9090+ "-c",
9191+ `stty raw -echo; cat > ${JSON.stringify(dumpFile)}`,
9292+ ]);
9393+ await new Promise((r) => setTimeout(r, 150));
9494+}
9595+9696+async function waitForDump(dumpFile: string, minBytes: number, timeoutMs: number): Promise<string> {
9797+ const start = Date.now();
9898+ while (Date.now() - start < timeoutMs) {
9999+ try {
100100+ const buf = fs.readFileSync(dumpFile);
101101+ if (buf.length >= minBytes) return buf.toString("utf-8");
102102+ } catch {}
103103+ await new Promise((r) => setTimeout(r, 50));
104104+ }
105105+ return fs.existsSync(dumpFile) ? fs.readFileSync(dumpFile).toString("utf-8") : "";
106106+}
107107+108108+afterEach(() => {
109109+ for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} }
110110+ bgPids = [];
111111+ for (const dir of sessionDirs) {
112112+ try {
113113+ for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} }
114114+ } catch {}
115115+ }
116116+ sessionDirs = [];
117117+});
118118+119119+describe("pty send --paste", () => {
120120+ it("wraps positional text in bracketed-paste markers", async () => {
121121+ const dir = makeSessionDir();
122122+ const name = uniqueName();
123123+ const dump = path.join(dir, "dump.bin");
124124+ await startDumpSession(dir, name, dump);
125125+126126+ const r = runCli(dir, "send", name, "--paste", "hello-paste");
127127+ expect(r.status).toBe(0);
128128+129129+ const received = await waitForDump(dump, "hello-paste".length + 12, 3000);
130130+ expect(received).toBe("\x1b[200~hello-paste\x1b[201~");
131131+ }, 15_000);
132132+133133+ it("works with --paste placed AFTER the text (filter extracts it regardless)", async () => {
134134+ const dir = makeSessionDir();
135135+ const name = uniqueName();
136136+ const dump = path.join(dir, "dump.bin");
137137+ await startDumpSession(dir, name, dump);
138138+139139+ const r = runCli(dir, "send", name, "post-paste", "--paste");
140140+ expect(r.status).toBe(0);
141141+142142+ const received = await waitForDump(dump, "post-paste".length + 12, 3000);
143143+ expect(received).toBe("\x1b[200~post-paste\x1b[201~");
144144+ }, 15_000);
145145+146146+ it("wraps an ordered --seq payload as a single paste", async () => {
147147+ const dir = makeSessionDir();
148148+ const name = uniqueName();
149149+ const dump = path.join(dir, "dump.bin");
150150+ await startDumpSession(dir, name, dump);
151151+152152+ const r = runCli(
153153+ dir, "send", name, "--paste",
154154+ "--seq", "first ",
155155+ "--seq", "second ",
156156+ "--seq", "third",
157157+ );
158158+ expect(r.status).toBe(0);
159159+160160+ const expected = "\x1b[200~first second third\x1b[201~";
161161+ const received = await waitForDump(dump, expected.length, 3000);
162162+ expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1);
163163+ expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1);
164164+ expect(received).toBe(expected);
165165+ }, 15_000);
166166+167167+ it("composes with --with-delay", async () => {
168168+ const dir = makeSessionDir();
169169+ const name = uniqueName();
170170+ const dump = path.join(dir, "dump.bin");
171171+ await startDumpSession(dir, name, dump);
172172+173173+ const r = runCli(
174174+ dir, "send", name, "--with-delay", "0.05", "--paste",
175175+ "--seq", "A",
176176+ "--seq", "B",
177177+ );
178178+ expect(r.status).toBe(0);
179179+180180+ const expected = "\x1b[200~AB\x1b[201~";
181181+ const received = await waitForDump(dump, expected.length, 3000);
182182+ expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1);
183183+ expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1);
184184+ expect(received).toBe(expected);
185185+ }, 15_000);
186186+187187+ it("without --paste, no bracketed-paste markers are emitted (control)", async () => {
188188+ const dir = makeSessionDir();
189189+ const name = uniqueName();
190190+ const dump = path.join(dir, "dump.bin");
191191+ await startDumpSession(dir, name, dump);
192192+193193+ const r = runCli(dir, "send", name, "plain-text");
194194+ expect(r.status).toBe(0);
195195+196196+ const received = await waitForDump(dump, "plain-text".length, 3000);
197197+ expect(received).toBe("plain-text");
198198+ expect(received).not.toContain("\x1b[200~");
199199+ expect(received).not.toContain("\x1b[201~");
200200+ }, 15_000);
201201+202202+ it("handles multi-line payload inside one paste event", async () => {
203203+ const dir = makeSessionDir();
204204+ const name = uniqueName();
205205+ const dump = path.join(dir, "dump.bin");
206206+ await startDumpSession(dir, name, dump);
207207+208208+ // A newline inside the paste is preserved as a literal byte between
209209+ // the markers; the receiver treats the whole thing as one paste.
210210+ const r = runCli(dir, "send", name, "--paste", "line-one\nline-two\n");
211211+ expect(r.status).toBe(0);
212212+213213+ const expected = "\x1b[200~line-one\nline-two\n\x1b[201~";
214214+ const received = await waitForDump(dump, expected.length, 3000);
215215+ expect(received).toBe(expected);
216216+ expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1);
217217+ expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1);
218218+ }, 15_000);
219219+});