Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pty run -a -- node server.js # create or attach if already
pty run -e -- npm test # ephemeral: auto-remove on exit
pty run --tag owner=forge -- node srv.js # tag a session with metadata
pty run --cwd /path -- node server.js # run in a specific directory
pty run -d --size 160x48 --id agent -- claude # pin the session's geometry (clients can't resize it)

pty rename my-label # inside a session: add/change its displayName
pty rename <ref> my-label # outside: set displayName on <ref>
Expand All @@ -71,6 +72,7 @@ pty list --filter-tag role=web # show only sessions with matching tag
pty attach myserver # reconnect to a session
pty attach -r myserver # reconnect, auto-restart if exited
pty attach --no-resize myserver # interactive viewer; preserve shared PTY geometry
pty resize myserver 160x48 # set + pin the session's geometry (one SIGWINCH)
pty exec -- codex # replace this session's process (inside a session)
pty peek myserver # print current screen and exit
pty peek --plain myserver # print as plain text (no ANSI)
Expand Down
11 changes: 9 additions & 2 deletions completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ _pty() {
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test remote-serve"
commands="run attach a resize exec peek send events list ls stats restart kill rm remove gc tag tag-multi emit rename up down test remote-serve"

if [[ ${COMP_CWORD} -eq 1 ]]; then
if [[ "${cur}" == -* ]]; then
Expand All @@ -24,7 +24,7 @@ _pty() {

case "${COMP_WORDS[1]}" in
run)
COMPREPLY=($(compgen -W "-d --detach -a --attach -e --ephemeral --id --name --no-display-name --tag --cwd --isolate-env --force" -- "${cur}"))
COMPREPLY=($(compgen -W "-d --detach -a --attach -e --ephemeral --id --name --no-display-name --tag --cwd --size --isolate-env --force" -- "${cur}"))
;;
attach|a)
if [[ "${cur}" == -* ]]; then
Expand All @@ -33,6 +33,13 @@ _pty() {
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
;;
resize)
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "" -- "${cur}"))
else
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
;;
exec)
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
;;
Expand Down
3 changes: 3 additions & 0 deletions completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ complete -c pty -n __pty_needs_command -l filter-tag -x -d 'TUI: filter to k=v (
complete -c pty -n __pty_needs_command -a run -d 'Create a session and attach'
complete -c pty -n __pty_needs_command -a attach -d 'Attach to an existing session'
complete -c pty -n __pty_needs_command -a a -d 'Attach to an existing session'
complete -c pty -n __pty_needs_command -a resize -d 'Set + pin a session\'s geometry'
complete -c pty -n __pty_needs_command -a exec -d 'Replace the current session process'
complete -c pty -n __pty_needs_command -a peek -d 'Print current screen (or follow / wait-for-text)'
complete -c pty -n __pty_needs_command -a send -d 'Send text or key events'
Expand Down Expand Up @@ -72,13 +73,15 @@ complete -c pty -n '__pty_using_command run' -l name -d 'Display label (any prin
complete -c pty -n '__pty_using_command run' -l no-display-name -d 'Skip the auto-generated label'
complete -c pty -n '__pty_using_command run' -l tag -d 'Tag session (k=v, repeatable)'
complete -c pty -n '__pty_using_command run' -l cwd -d 'Working directory'
complete -c pty -n '__pty_using_command run' -l size -d 'Pin session geometry (<cols>x<rows>)'
complete -c pty -n '__pty_using_command run' -l isolate-env -d 'Scrub env to a safe allow-list'
complete -c pty -n '__pty_using_command run' -l force -d 'Create even from inside another pty'
complete -c pty -n '__pty_using_command attach a' -l auto-restart -s r -d 'Auto-restart if the session is exited'
complete -c pty -n '__pty_using_command attach a' -l no-resize -d 'Preserve shared PTY geometry'
complete -c pty -n '__pty_using_command attach a' -l force -d 'Attach even from inside another pty'
complete -c pty -n '__pty_using_command attach a' -l remote -d 'Attach a session on a fabric peer'
complete -c pty -n '__pty_using_command attach a' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command resize' -a '(__pty_sessions)' -d 'Session'
complete -c pty -n '__pty_using_command exec' -F
complete -c pty -n '__pty_using_command peek' -l follow -s f -d 'Follow output read-only'
complete -c pty -n '__pty_using_command peek' -l plain -d 'Plain text (no ANSI)'
Expand Down
6 changes: 6 additions & 0 deletions completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ _pty() {
'run:Create a session and attach'
'attach:Attach to an existing session'
'a:Alias for attach'
'resize:Set + pin a session's geometry'
'exec:Replace the current session process'
'peek:Print current screen (or follow / wait-for-text)'
'send:Send text or key events'
Expand Down Expand Up @@ -62,6 +63,7 @@ _pty() {
'--no-display-name[Skip the auto-generated label]' \
'--tag[Tag session (k=v, repeatable)]' \
'--cwd[Working directory]' \
'--size[Pin session geometry (<cols>x<rows>)]' \
'--isolate-env[Scrub env to a safe allow-list]' \
'--force[Create even from inside another pty]'
;;
Expand All @@ -73,6 +75,10 @@ _pty() {
'--remote[Attach a session on a fabric peer]' \
'1:session:_pty_sessions'
;;
resize)
_arguments \
'1:session:_pty_sessions'
;;
exec)
_arguments \
'1:directory:_directories'
Expand Down
83 changes: 81 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from "node:path";
import * as readline from "node:readline/promises";
import { spawnSync, execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import * as net from "node:net";
import { attach, peek, send, queryStats, resolveSeqDelayMs, type StatsResult } from "./client.ts";
import { printVersion } from "./version.ts";
import { parseSeqValue } from "./keys.ts";
Expand All @@ -27,10 +28,12 @@ import {
writeMetadata,
atomicWriteFileSync,
getSessionDir,
getSocketPath,
DEFAULT_SESSION_DIR,
type SessionInfo,
type SessionMetadata,
} from "./sessions.ts";
import { encodeResizeAuthoritative } from "./protocol.ts";
import { spawnDaemon, resolveCommand } from "./spawn.ts";
import {
EventFollower, EventWriter, EventType,
Expand Down Expand Up @@ -92,12 +95,14 @@ Flags:
-e, --ephemeral Auto-remove metadata on clean exit
--tag key=value Tag the session (repeatable)
--cwd <path> Working directory for the command
--size <cols>x<rows> Pin the session's geometry; attaching clients can no longer resize it
--isolate-env Scrub the child env to a safe allow-list (for remote-reachable sessions)
--force Create even from inside another pty session (bypass the nesting guard)

Examples:
pty run -- node server.js
pty run -d --name "API" --tag role=web -- node server.js`,
pty run -d --name "API" --tag role=web -- node server.js
pty run -d --size 160x48 --id agent -- claude`,

attach: `Usage: pty attach [-r] [--no-resize] [--force] [--remote <peer>] <ref>

Expand All @@ -115,6 +120,19 @@ Examples:
pty attach -r myserver
pty attach --remote hetzner myshell`,

resize: `Usage: pty resize <ref> <cols>x<rows>

Set a session's authoritative geometry and pin it. The child gets exactly one
SIGWINCH. Once pinned, attaching clients no longer renegotiate the size — the
session owns its geometry until the next resize.

Use this to give a headless/no-tty session a fixed render size, so a plain
attach from a small terminal can't reflow a running TUI.

Examples:
pty resize myagent 160x48
pty stats myagent --json # inspect geometry.owner / geometry.pinned`,

exec: `Usage: pty exec -- <command> [args...]

Replace the current session's leaf process with a new command. Run INSIDE a
Expand Down Expand Up @@ -395,6 +413,7 @@ Create sessions:
pty run -e -- <command> Ephemeral: auto-remove metadata on clean exit
pty run --tag key=value -- <command> Tag a session (repeatable)
pty run --cwd /path -- <command> Run in a specific directory
pty run --size <cols>x<rows> -- <cmd> Pin the session's geometry (clients can't resize it)
pty run --isolate-env -- <command> Scrub the child env to a safe allow-list
(intended for remote-reachable sessions)
pty run --force -- <command> Create even from inside another pty session (nested)
Expand All @@ -405,6 +424,7 @@ Attach & interact:
pty attach --force <ref> Attach even from inside another pty session (nested)
pty attach -r <ref> Attach, auto-restart if the session is exited
pty attach --remote <peer> <ref> Attach a session on a fabric peer (over fabric)
pty resize <ref> <cols>x<rows> Set + pin the session's geometry (one SIGWINCH)
pty exec -- <command> [args...] Replace the current session's process (inside a session)
pty send <ref> "text" Send raw text (no implicit newline)
pty send <ref> --seq "text" --seq key:return Send an ordered sequence of chunks / key events
Expand Down Expand Up @@ -481,6 +501,7 @@ Global:
pty help | pty --help | pty -h Show this usage
pty version | pty --version | pty -v Print the version (<semver>+<short-sha>)
pty test [watch | -t "pattern"] Run the pty test suite (vitest passthrough)
pty completions <shell> Print a fish/bash/zsh completion script to stdout

Session references (<ref>): the on-disk id (validated: [A-Za-z0-9._-], ≤ 255 chars,
socket path ≤ 104 bytes), or a displayName. Inside a session, most commands default
Expand Down Expand Up @@ -675,6 +696,7 @@ async function main(): Promise<void> {
let explicitId: string | null = null;
let explicitDisplayName: string | null = null;
let cwd: string | null = null;
let size: { cols: number; rows: number } | null = null;
const tags: Record<string, string> = {};
let i = 1;
while (i < args.length && args[i] !== "--") {
Expand All @@ -687,6 +709,15 @@ async function main(): Promise<void> {
else if (args[i] === "--id" && i + 1 < args.length) { explicitId = args[i + 1]; i += 2; }
else if (args[i] === "--name" && i + 1 < args.length) { explicitDisplayName = args[i + 1]; i += 2; }
else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; }
else if (args[i] === "--size" && i + 1 < args.length) {
const m = /^(\d+)x(\d+)$/.exec(args[i + 1]);
if (!m) {
console.error(`Invalid --size "${args[i + 1]}". Use --size <cols>x<rows>, e.g. --size 160x48`);
process.exit(1);
}
size = { cols: parseInt(m[1], 10), rows: parseInt(m[2], 10) };
i += 2;
}
else if (args[i] === "--tag" && i + 1 < args.length) {
const eq = args[i + 1].indexOf("=");
if (eq === -1) {
Expand Down Expand Up @@ -847,7 +878,7 @@ async function main(): Promise<void> {
}
}

await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName);
await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName, size);
break;
}

Expand Down Expand Up @@ -900,6 +931,22 @@ async function main(): Promise<void> {
break;
}

case "resize": {
// `pty resize <name> <cols>x<rows>` — set the authoritative session-owned
// geometry. Works on any running session and pins it going forward, so
// subsequent attaches (neutral or not) can no longer reflow the child.
const resizeName = args[1];
const spec = args[2];
const m = spec ? /^(\d+)x(\d+)$/.exec(spec) : null;
if (!resizeName || !m) {
console.error("Usage: pty resize <ref> <cols>x<rows>");
process.exit(1);
}
const resolvedResizeName = await resolveRef(resizeName);
await cmdResize(resolvedResizeName, parseInt(m[1], 10), parseInt(m[2], 10));
break;
}

case "exec": {
// pty exec -- <command> [args...]
const dashDash = args.indexOf("--", 1);
Expand Down Expand Up @@ -1494,6 +1541,7 @@ async function cmdRun(
explicitCwd: string | null = null,
isolateEnv = false,
displayName: string | null = null,
size: { cols: number; rows: number } | null = null,
): Promise<void> {
const session = await getSession(name);
if (session?.status === "running") {
Expand Down Expand Up @@ -1535,6 +1583,9 @@ async function cmdRun(
name, command, args, displayCommand, cwd: cwdOpt, ephemeral, tags: tagOpt,
...(displayNameOpt ? { displayName: displayNameOpt } : {}),
...(isolateEnv ? { isolateEnv: true } : {}),
// `--size` authors an authoritative, pinned session geometry: the child
// renders at a fixed size regardless of who attaches later.
...(size ? { rows: size.rows, cols: size.cols, pinGeometry: true } : {}),
});
} finally {
releaseLock(name);
Expand Down Expand Up @@ -1640,6 +1691,34 @@ async function handleDeadSession(
doAttach(session.name, geometryNeutral);
}

/** Send an authoritative RESIZE to a running session, pinning its geometry.
* Goes straight to the session socket rather than through attach() — this is
* a one-shot control message, not a client joining the session. */
async function cmdResize(name: string, cols: number, rows: number): Promise<void> {
if (cols <= 0 || rows <= 0) {
console.error(`Invalid size ${cols}x${rows}.`);
process.exit(1);
}
const socketPath = getSocketPath(name);
await new Promise<void>((resolve) => {
const socket = net.createConnection(socketPath);
socket.on("connect", () => {
socket.write(encodeResizeAuthoritative(rows, cols));
socket.end();
});
socket.on("finish", () => resolve());
socket.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
console.error(`Session "${name}" not found or not running.`);
} else {
console.error(`Connection error: ${err.message}`);
}
process.exit(1);
});
});
console.log(`Session "${name}" resized to ${cols}x${rows} (session-owned).`);
}

function doAttach(name: string, geometryNeutral = false): void {
attach({
name,
Expand Down
8 changes: 7 additions & 1 deletion src/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ const COMMANDS: readonly CommandSpec[] = [
{ name: "no-display-name", desc: "Skip the auto-generated label" },
{ name: "tag", desc: "Tag session (k=v, repeatable)" },
{ name: "cwd", desc: "Working directory" },
{ name: "size", desc: "Pin session geometry (<cols>x<rows>)" },
{ name: "isolate-env", desc: "Scrub env to a safe allow-list" },
{ name: "force", desc: "Create even from inside another pty" },
],
Expand All @@ -89,6 +90,11 @@ const COMMANDS: readonly CommandSpec[] = [
{ name: "remote", desc: "Attach a session on a fabric peer" },
],
},
{
name: "resize",
desc: "Set + pin a session's geometry",
dynamic: "sessions",
},
{
name: "exec",
desc: "Replace the current session process",
Expand Down Expand Up @@ -522,7 +528,7 @@ const SHELLS = Object.keys(GENERATORS);

function usageText(): string {
return (
"usage: pty completions <shell>\n\n" +
"Usage: pty completions <shell>\n\n" +
"Print a shell completion script to stdout.\n\n" +
"Shells:\n" +
SHELLS.map((s) => ` ${s}`).join("\n") +
Expand Down
18 changes: 18 additions & 0 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export function encodeAttach(
return encodePacket(MessageType.ATTACH, payload);
}

/** Optional RESIZE flag: this resize is authoritative (from `pty resize`) and
* sets the session-owned geometry directly, bypassing min-wins negotiation.
* Shares byte 4 with the ATTACH flags — same layout, different message type. */
export const RESIZE_FLAG_AUTHORITATIVE = 0x01;

/** Read the optional flags byte from an ATTACH or RESIZE payload (0 if absent),
* so a legacy 4-byte frame decodes as "no flags" rather than throwing. */
export function decodeAttachFlags(payload: Buffer): number {
return payload.length >= 5 ? payload.readUInt8(4) : 0;
}
Expand All @@ -85,6 +92,17 @@ export function encodeResize(rows: number, cols: number): Buffer {
return encodePacket(MessageType.RESIZE, payload);
}

/** A RESIZE that sets the session-owned geometry (`pty resize`). Unlike a
* client RESIZE this is not a vote in min-wins negotiation — it pins the size
* and the child gets exactly one legitimate SIGWINCH. */
export function encodeResizeAuthoritative(rows: number, cols: number): Buffer {
const payload = Buffer.alloc(5);
payload.writeUInt16BE(rows, 0);
payload.writeUInt16BE(cols, 2);
payload.writeUInt8(RESIZE_FLAG_AUTHORITATIVE, 4);
return encodePacket(MessageType.RESIZE, payload);
}

export function encodeExit(code: number): Buffer {
const payload = Buffer.alloc(4);
payload.writeInt32BE(code, 0);
Expand Down
Loading
Loading