This repository has no description
0

Configure Feed

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

Add pty send --paste (bracketed paste wrap)

Nathan Herald (Apr 17, 2026, 5:10 PM +0200) dd68da3c d584e4e4

+371 -2
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Send 6 + - 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. 7 + 5 8 ### Client API 6 9 - 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. 7 10 - 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
··· 84 84 pty send myserver $'hello\n' # send text with newline (shell syntax) 85 85 pty send myserver --seq "git status" --seq key:return # ordered sequence 86 86 pty send myserver --seq key:ctrl+c # send control keys 87 + pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste 87 88 88 89 pty stats # live metrics for all sessions 89 90 pty stats myserver # stats for a specific session
+14 -1
src/cli.ts
··· 75 75 pty send <name> "text" Send text to a session 76 76 pty send <name> --seq "text" --seq key:return Send an ordered sequence 77 77 pty send <name> --with-delay 0.5 --seq ... Delay between each --seq item 78 + pty send <name> --paste "<big text>" Wrap payload in bracketed-paste markers 78 79 pty restart <name> Restart a session (prompts if running) 79 80 pty restart -y <name> Restart without confirmation 80 81 pty events <name> Follow events from a session ··· 410 411 } 411 412 412 413 let sendArgs = args.slice(2); 414 + // --paste can appear anywhere; pull it out before the rest of the 415 + // parsing so its position relative to --seq / text doesn't matter. 416 + let paste = false; 417 + sendArgs = sendArgs.filter((a) => { 418 + if (a === "--paste") { paste = true; return false; } 419 + return true; 420 + }); 413 421 let delaySecs: number | undefined; 414 422 if (sendArgs[0] === "--with-delay") { 415 423 sendArgs = sendArgs.slice(1); ··· 454 462 } 455 463 456 464 const resolvedSendName = await resolveRef(sendName); 457 - send({ name: resolvedSendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined }); 465 + send({ 466 + name: resolvedSendName, 467 + data, 468 + delayMs: delaySecs != null ? delaySecs * 1000 : undefined, 469 + ...(paste ? { paste: true } : {}), 470 + }); 458 471 break; 459 472 } 460 473
+15
src/client.ts
··· 13 13 } from "./protocol.ts"; 14 14 import { getSocketPath } from "./sessions.ts"; 15 15 import { stripAnsi } from "./tui/colors.ts"; 16 + import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts"; 16 17 17 18 const DETACH_KEY = 0x1c; // Ctrl+\ (legacy encoding) 18 19 const DETACH_KEY_KITTY = "\x1b[92;5u"; // Ctrl+\ (Kitty keyboard protocol) ··· 160 161 name: string; 161 162 data: string[]; 162 163 delayMs?: number; 164 + /** Wrap the entire payload (all `data` entries taken together) in 165 + * bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI 166 + * treats everything between the markers as one paste event rather 167 + * than a sequence of keystrokes — useful for injecting multi-line 168 + * prompts into agent sessions without premature submission. Receiver 169 + * must have bracketed paste enabled (DECSET 2004); most modern 170 + * shells and TUIs do by default. */ 171 + paste?: boolean; 163 172 } 164 173 165 174 /** Send data to a session without attaching. Silent on success. */ ··· 168 177 const socket = net.createConnection(socketPath); 169 178 170 179 socket.on("connect", async () => { 180 + if (options.paste && options.data.length > 0) { 181 + socket.write(encodeData(BRACKETED_PASTE_START)); 182 + } 171 183 for (let i = 0; i < options.data.length; i++) { 172 184 if (i > 0 && options.delayMs) { 173 185 await new Promise((resolve) => setTimeout(resolve, options.delayMs)); 174 186 } 175 187 socket.write(encodeData(options.data[i])); 188 + } 189 + if (options.paste && options.data.length > 0) { 190 + socket.write(encodeData(BRACKETED_PASTE_END)); 176 191 } 177 192 socket.end(); 178 193 });
+15
src/connection.ts
··· 12 12 } from "./protocol.ts"; 13 13 import { getSocketPath } from "./sessions.ts"; 14 14 import { resolveKey } from "./keys.ts"; 15 + import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts"; 15 16 16 17 export interface SessionConnectionOptions { 17 18 name: string; ··· 23 24 name: string; 24 25 data: string[]; 25 26 delayMs?: number; 27 + /** Wrap the entire payload (all `data` entries taken together) in 28 + * bracketed-paste markers (CSI 200 ~ … CSI 201 ~). The receiving TUI 29 + * treats everything between the markers as one paste event rather 30 + * than a sequence of keystrokes — useful for injecting multi-line 31 + * prompts into agent sessions without premature submission. Receiver 32 + * must have bracketed paste enabled (DECSET 2004); most modern 33 + * shells and TUIs do by default. */ 34 + paste?: boolean; 26 35 } 27 36 28 37 export interface PeekScreenOptions { ··· 157 166 const socket = net.createConnection(socketPath); 158 167 159 168 socket.on("connect", async () => { 169 + if (options.paste && options.data.length > 0) { 170 + socket.write(encodeData(BRACKETED_PASTE_START)); 171 + } 160 172 for (let i = 0; i < options.data.length; i++) { 161 173 if (i > 0 && options.delayMs) { 162 174 await new Promise((r) => setTimeout(r, options.delayMs)); 163 175 } 164 176 socket.write(encodeData(options.data[i])); 177 + } 178 + if (options.paste && options.data.length > 0) { 179 + socket.write(encodeData(BRACKETED_PASTE_END)); 165 180 } 166 181 socket.end(); 167 182 });
+16
src/paste.ts
··· 1 + // Bracketed paste (DECSET 2004) helpers. 2 + // 3 + // When a receiving terminal has bracketed paste mode enabled, pasted 4 + // text is wrapped in these markers so applications can distinguish 5 + // "user typed this" from "user pasted this". Shells suppress 6 + // history/autocomplete during paste, vim enters paste mode, and TUI 7 + // agents (claude, aider) treat the block as one input event rather 8 + // than a sequence of keystrokes. 9 + // 10 + // `send --paste` wraps the entire payload in START…END so multi-line 11 + // prompts injected into agent sessions don't get submitted partway. 12 + 13 + /** Sent BEFORE pasted content. CSI 200 ~. */ 14 + export const BRACKETED_PASTE_START = "\x1b[200~"; 15 + /** Sent AFTER pasted content. CSI 201 ~. */ 16 + export const BRACKETED_PASTE_END = "\x1b[201~";
+88 -1
tests/connection.test.ts
··· 208 208 const dir = makeSessionDir(); 209 209 const name = uniqueName(); 210 210 process.env.PTY_SESSION_DIR = dir; 211 - await startDaemon(dir, name, "cat"); 211 + // `stty raw -echo` lets the ESC markers appear verbatim in the 212 + // buffer instead of being munged to `^[` by ECHOCTL. 213 + await startDaemon(dir, name, "sh", ["-c", "stty raw -echo; cat"]); 214 + await new Promise((r) => setTimeout(r, 150)); 212 215 213 216 await sendData({ name, data: ["hello-send"] }); 214 217 ··· 225 228 await expect( 226 229 sendData({ name: "nonexistent", data: ["test"] }) 227 230 ).rejects.toThrow("not found or not running"); 231 + }, 15000); 232 + 233 + // xterm-headless parses bracketed-paste START/END as valid CSI 234 + // sequences and absorbs them — they don't appear in the rendered 235 + // buffer. To verify the raw bytes that reach the child, we use a 236 + // child that dumps its stdin to a file and then read the file. 237 + // `stty raw -echo` keeps line discipline from munging control bytes. 238 + async function startDumper(dir: string, name: string, dumpFile: string) { 239 + await startDaemon(dir, name, "sh", [ 240 + "-c", 241 + `stty raw -echo; cat > ${JSON.stringify(dumpFile)}`, 242 + ]); 243 + await new Promise((r) => setTimeout(r, 150)); 244 + } 245 + 246 + async function waitForDump(dumpFile: string, minBytes: number, timeoutMs: number): Promise<Buffer> { 247 + const start = Date.now(); 248 + while (Date.now() - start < timeoutMs) { 249 + try { 250 + const buf = fs.readFileSync(dumpFile); 251 + if (buf.length >= minBytes) return buf; 252 + } catch {} 253 + await new Promise((r) => setTimeout(r, 50)); 254 + } 255 + return fs.existsSync(dumpFile) ? fs.readFileSync(dumpFile) : Buffer.alloc(0); 256 + } 257 + 258 + it("paste:true wraps the payload in bracketed-paste markers", async () => { 259 + const dir = makeSessionDir(); 260 + const name = uniqueName(); 261 + process.env.PTY_SESSION_DIR = dir; 262 + const dump = path.join(dir, "dump.bin"); 263 + await startDumper(dir, name, dump); 264 + 265 + await sendData({ name, data: ["hello-paste"], paste: true }); 266 + const received = await waitForDump(dump, "hello-paste".length + 12, 3000); 267 + const text = received.toString("utf-8"); 268 + expect(text).toBe("\x1b[200~hello-paste\x1b[201~"); 269 + }, 15000); 270 + 271 + it("paste:true wraps a multi-item payload as one bracket pair (not per item)", async () => { 272 + const dir = makeSessionDir(); 273 + const name = uniqueName(); 274 + process.env.PTY_SESSION_DIR = dir; 275 + const dump = path.join(dir, "dump.bin"); 276 + await startDumper(dir, name, dump); 277 + 278 + await sendData({ name, data: ["line1\n", "line2\n", "line3"], paste: true }); 279 + const expected = "\x1b[200~line1\nline2\nline3\x1b[201~"; 280 + const received = await waitForDump(dump, expected.length, 3000); 281 + const text = received.toString("utf-8"); 282 + expect((text.match(/\x1b\[200~/g) ?? []).length).toBe(1); 283 + expect((text.match(/\x1b\[201~/g) ?? []).length).toBe(1); 284 + expect(text).toBe(expected); 285 + }, 15000); 286 + 287 + it("paste:false (default) does not add bracketed-paste markers", async () => { 288 + const dir = makeSessionDir(); 289 + const name = uniqueName(); 290 + process.env.PTY_SESSION_DIR = dir; 291 + const dump = path.join(dir, "dump.bin"); 292 + await startDumper(dir, name, dump); 293 + 294 + await sendData({ name, data: ["no-paste"] }); 295 + const received = await waitForDump(dump, "no-paste".length, 3000); 296 + expect(received.toString("utf-8")).toBe("no-paste"); 297 + expect(received.toString("utf-8")).not.toContain("\x1b[200~"); 298 + expect(received.toString("utf-8")).not.toContain("\x1b[201~"); 299 + }, 15000); 300 + 301 + it("paste:true on an empty data array does not emit markers alone", async () => { 302 + const dir = makeSessionDir(); 303 + const name = uniqueName(); 304 + process.env.PTY_SESSION_DIR = dir; 305 + const dump = path.join(dir, "dump.bin"); 306 + await startDumper(dir, name, dump); 307 + 308 + // No content → no markers. (Sending nothing shouldn't write a bare 309 + // START/END pair to the session.) 310 + await sendData({ name, data: [], paste: true }); 311 + await new Promise((r) => setTimeout(r, 300)); 312 + 313 + const received = fs.existsSync(dump) ? fs.readFileSync(dump) : Buffer.alloc(0); 314 + expect(received.length).toBe(0); 228 315 }, 15000); 229 316 }); 230 317
+219
tests/send-paste.test.ts
··· 1 + // CLI-surface tests for `pty send --paste`. Exercises the binary via 2 + // spawnSync so the argv-parsing path is covered end-to-end, including 3 + // the `--paste` flag's position-independence (before/after --seq, with 4 + // --with-delay, etc.). 5 + 6 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 7 + import * as fs from "node:fs"; 8 + import * as os from "node:os"; 9 + import * as path from "node:path"; 10 + import { fileURLToPath } from "node:url"; 11 + import { spawn, spawnSync } from "node:child_process"; 12 + 13 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 14 + const nodeBin = process.execPath; 15 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 16 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 17 + 18 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-paste-")); 19 + afterAll(() => { 20 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 21 + }); 22 + 23 + let bgPids: number[] = []; 24 + let sessionDirs: string[] = []; 25 + 26 + function makeSessionDir(): string { 27 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 28 + sessionDirs.push(dir); 29 + return dir; 30 + } 31 + 32 + let nameCounter = 0; 33 + function uniqueName(): string { 34 + return `sp${++nameCounter}${Math.random().toString(36).slice(2, 5)}`; 35 + } 36 + 37 + async function startDaemon( 38 + sessionDir: string, 39 + name: string, 40 + command: string, 41 + args: string[] = [], 42 + ): Promise<number> { 43 + const config = JSON.stringify({ 44 + name, command, args, displayCommand: command, 45 + cwd: os.tmpdir(), rows: 24, cols: 80, 46 + }); 47 + const child = spawn(nodeBin, [serverModule], { 48 + detached: true, 49 + stdio: ["ignore", "ignore", "pipe"], 50 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 51 + }); 52 + let stderr = ""; 53 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 54 + let exitCode: number | null = null; 55 + child.on("exit", (code) => { exitCode = code; }); 56 + (child.stderr as any)?.unref?.(); 57 + child.unref(); 58 + 59 + const socketPath = path.join(sessionDir, `${name}.sock`); 60 + const start = Date.now(); 61 + while (Date.now() - start < 5000) { 62 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 63 + try { 64 + fs.statSync(socketPath); 65 + await new Promise((r) => setTimeout(r, 100)); 66 + bgPids.push(child.pid!); 67 + return child.pid!; 68 + } catch {} 69 + await new Promise((r) => setTimeout(r, 50)); 70 + } 71 + throw new Error(`Timeout waiting for daemon`); 72 + } 73 + 74 + function runCli(sessionDir: string, ...args: string[]) { 75 + return spawnSync(nodeBin, [cliPath, ...args], { 76 + cwd: os.tmpdir(), 77 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 78 + encoding: "utf-8", 79 + timeout: 10_000, 80 + }); 81 + } 82 + 83 + /** Run a dump-to-file child in the session so we can read the raw bytes 84 + * the `send` write produced — xterm-headless interprets bracketed-paste 85 + * START/END as valid CSI sequences and absorbs them, so peeking the 86 + * screen wouldn't show the markers. `stty raw -echo` keeps the PTY line 87 + * discipline from munging or echoing the ESC bytes. */ 88 + async function startDumpSession(sessionDir: string, name: string, dumpFile: string) { 89 + await startDaemon(sessionDir, name, "sh", [ 90 + "-c", 91 + `stty raw -echo; cat > ${JSON.stringify(dumpFile)}`, 92 + ]); 93 + await new Promise((r) => setTimeout(r, 150)); 94 + } 95 + 96 + async function waitForDump(dumpFile: string, minBytes: number, timeoutMs: number): Promise<string> { 97 + const start = Date.now(); 98 + while (Date.now() - start < timeoutMs) { 99 + try { 100 + const buf = fs.readFileSync(dumpFile); 101 + if (buf.length >= minBytes) return buf.toString("utf-8"); 102 + } catch {} 103 + await new Promise((r) => setTimeout(r, 50)); 104 + } 105 + return fs.existsSync(dumpFile) ? fs.readFileSync(dumpFile).toString("utf-8") : ""; 106 + } 107 + 108 + afterEach(() => { 109 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 110 + bgPids = []; 111 + for (const dir of sessionDirs) { 112 + try { 113 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 114 + } catch {} 115 + } 116 + sessionDirs = []; 117 + }); 118 + 119 + describe("pty send --paste", () => { 120 + it("wraps positional text in bracketed-paste markers", async () => { 121 + const dir = makeSessionDir(); 122 + const name = uniqueName(); 123 + const dump = path.join(dir, "dump.bin"); 124 + await startDumpSession(dir, name, dump); 125 + 126 + const r = runCli(dir, "send", name, "--paste", "hello-paste"); 127 + expect(r.status).toBe(0); 128 + 129 + const received = await waitForDump(dump, "hello-paste".length + 12, 3000); 130 + expect(received).toBe("\x1b[200~hello-paste\x1b[201~"); 131 + }, 15_000); 132 + 133 + it("works with --paste placed AFTER the text (filter extracts it regardless)", async () => { 134 + const dir = makeSessionDir(); 135 + const name = uniqueName(); 136 + const dump = path.join(dir, "dump.bin"); 137 + await startDumpSession(dir, name, dump); 138 + 139 + const r = runCli(dir, "send", name, "post-paste", "--paste"); 140 + expect(r.status).toBe(0); 141 + 142 + const received = await waitForDump(dump, "post-paste".length + 12, 3000); 143 + expect(received).toBe("\x1b[200~post-paste\x1b[201~"); 144 + }, 15_000); 145 + 146 + it("wraps an ordered --seq payload as a single paste", async () => { 147 + const dir = makeSessionDir(); 148 + const name = uniqueName(); 149 + const dump = path.join(dir, "dump.bin"); 150 + await startDumpSession(dir, name, dump); 151 + 152 + const r = runCli( 153 + dir, "send", name, "--paste", 154 + "--seq", "first ", 155 + "--seq", "second ", 156 + "--seq", "third", 157 + ); 158 + expect(r.status).toBe(0); 159 + 160 + const expected = "\x1b[200~first second third\x1b[201~"; 161 + const received = await waitForDump(dump, expected.length, 3000); 162 + expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1); 163 + expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1); 164 + expect(received).toBe(expected); 165 + }, 15_000); 166 + 167 + it("composes with --with-delay", async () => { 168 + const dir = makeSessionDir(); 169 + const name = uniqueName(); 170 + const dump = path.join(dir, "dump.bin"); 171 + await startDumpSession(dir, name, dump); 172 + 173 + const r = runCli( 174 + dir, "send", name, "--with-delay", "0.05", "--paste", 175 + "--seq", "A", 176 + "--seq", "B", 177 + ); 178 + expect(r.status).toBe(0); 179 + 180 + const expected = "\x1b[200~AB\x1b[201~"; 181 + const received = await waitForDump(dump, expected.length, 3000); 182 + expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1); 183 + expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1); 184 + expect(received).toBe(expected); 185 + }, 15_000); 186 + 187 + it("without --paste, no bracketed-paste markers are emitted (control)", async () => { 188 + const dir = makeSessionDir(); 189 + const name = uniqueName(); 190 + const dump = path.join(dir, "dump.bin"); 191 + await startDumpSession(dir, name, dump); 192 + 193 + const r = runCli(dir, "send", name, "plain-text"); 194 + expect(r.status).toBe(0); 195 + 196 + const received = await waitForDump(dump, "plain-text".length, 3000); 197 + expect(received).toBe("plain-text"); 198 + expect(received).not.toContain("\x1b[200~"); 199 + expect(received).not.toContain("\x1b[201~"); 200 + }, 15_000); 201 + 202 + it("handles multi-line payload inside one paste event", async () => { 203 + const dir = makeSessionDir(); 204 + const name = uniqueName(); 205 + const dump = path.join(dir, "dump.bin"); 206 + await startDumpSession(dir, name, dump); 207 + 208 + // A newline inside the paste is preserved as a literal byte between 209 + // the markers; the receiver treats the whole thing as one paste. 210 + const r = runCli(dir, "send", name, "--paste", "line-one\nline-two\n"); 211 + expect(r.status).toBe(0); 212 + 213 + const expected = "\x1b[200~line-one\nline-two\n\x1b[201~"; 214 + const received = await waitForDump(dump, expected.length, 3000); 215 + expect(received).toBe(expected); 216 + expect((received.match(/\x1b\[200~/g) ?? []).length).toBe(1); 217 + expect((received.match(/\x1b\[201~/g) ?? []).length).toBe(1); 218 + }, 15_000); 219 + });