Skip to content
Merged
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
8 changes: 4 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.12.0

### Attach no longer nudges a child that is already at the right size

- The attach-time redraw nudge (resize to `cols - 1` and back, to paper over serialize-replay artifacts) now fires only when the attaching client's geometry differs from the session's. Attaching at the size the session already has leaves the child alone, so connecting a viewer no longer delivers `SIGWINCH` to an idle process.

### Package renamed: `@myobie/pty` → `@compoundingtech/pty`

- The npm scope + GitHub org moved from `@myobie`/`myobie` to `@compoundingtech`/`compoundingtech`. The package is now **`@compoundingtech/pty`**; all subpaths (`/tui`, `/client`, `/server`, `/testing`, `/protocol`, `/keys`) and the CLI command (`pty`) are unchanged in shape. Update imports and any `@myobie/pty*` dependency specifiers.
Expand Down Expand Up @@ -45,10 +49,6 @@

- New `badge(label, opts)` widget renders a small SRCL-style chip: an uppercase, padded label on a muted fill (mirroring SRCL's `Badge` — `text-transform: uppercase`, `padding: 0 1ch`, filled background). Status `variant`s (`neutral | ok | warn | error | accent | info`) color the label, or fill the chip when `solid: true`. `uppercase` and `bold` are configurable. Returns a `TextNode` styled with semantic color tokens only (no terminal-only escapes), so the same call can render under a non-terminal backend.

### Geometry-neutral interactive attach

- `pty attach --no-resize <ref>` receives the live screen and forwards input without contributing its terminal dimensions to the shared PTY's min-wins size negotiation. It also suppresses the attach-time redraw `SIGWINCH` nudge. This supports embedded interactive viewers whose stdio is piped (and would otherwise report the 80x24 fallback) while leaving ordinary multi-viewer behavior unchanged. The CLI refuses the flag against a daemon that does not advertise `capabilities.geometryNeutralAttach`, rather than silently degrading.

### `pty.toml` — per-session `cwd` field

- Sessions in a `pty.toml` accept an optional `cwd = "..."` setting the working directory. An absolute path is used as-is; a relative path resolves against the manifest's directory. Omitted → the session runs in the manifest's directory (the unchanged default).
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ 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 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
2 changes: 1 addition & 1 deletion completions/pty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ _pty() {
;;
attach|a)
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "-r --auto-restart --no-resize --force --remote" -- "${cur}"))
COMPREPLY=($(compgen -W "-r --auto-restart --force --remote" -- "${cur}"))
else
COMPREPLY=($(compgen -W "${names}" -- "${cur}"))
fi
Expand Down
1 change: 0 additions & 1 deletion completions/pty.fish
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ complete -c pty -n '__pty_using_command run' -l cwd -d 'Working directory'
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'
Expand Down
1 change: 0 additions & 1 deletion completions/pty.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ _pty() {
attach|a)
_arguments \
'(r --auto-restart){r,--auto-restart}[Auto-restart if the session is exited]' \
'--no-resize[Preserve shared PTY geometry]' \
'--force[Attach even from inside another pty]' \
'--remote[Attach a session on a fabric peer]' \
'1:session:_pty_sessions'
Expand Down
41 changes: 7 additions & 34 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,12 @@ Examples:
pty run -- node server.js
pty run -d --name "API" --tag role=web -- node server.js`,

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

Reconnect to a session (alias: pty a). Detach again with Ctrl+\\.

Flags:
-r, --auto-restart Auto-restart the session if it has exited
--no-resize Send input and receive output without changing PTY geometry
--force Attach even from inside another pty session (nested)
--remote <peer> Attach a session on a fabric peer (over fabric); <ref> is
the session's name/id ON THE REMOTE
Expand Down Expand Up @@ -401,7 +400,6 @@ Create sessions:

Attach & interact:
pty attach <ref> Attach to an existing session (alias: pty a)
pty attach --no-resize <ref> Attach without changing shared PTY geometry
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)
Expand Down Expand Up @@ -855,13 +853,11 @@ async function main(): Promise<void> {
case "a": {
let autoRestart = false;
let force = false;
let geometryNeutral = false;
let attachName: string | null = null;
let attachRemotePeer: string | null = null;
for (let ai = 1; ai < args.length; ai++) {
const a = args[ai];
if (a === "--auto-restart" || a === "-r") autoRestart = true;
else if (a === "--no-resize") geometryNeutral = true;
else if (a === "--force") force = true;
else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; }
else if (!attachName) attachName = a;
Expand All @@ -871,7 +867,7 @@ async function main(): Promise<void> {
}
}
if (!attachName) {
console.error("Usage: pty attach [-r|--auto-restart] [--no-resize] [--force] [--remote <peer>] <name>");
console.error("Usage: pty attach [-r|--auto-restart] [--force] [--remote <peer>] <name>");
process.exit(1);
}
// Nesting guard runs BEFORE name validation / ref resolution. A nested
Expand All @@ -887,15 +883,11 @@ async function main(): Promise<void> {
" Pass --force to attach anyway (nested clients are usually a mistake).",
});
if (attachRemotePeer) {
if (geometryNeutral) {
console.error("pty attach: --no-resize is not yet supported with --remote");
process.exit(1);
}
// The name is the session's id ON THE REMOTE — don't resolve locally.
await cmdAttachRemote(attachRemotePeer, attachName);
} else {
const resolvedAttachName = await resolveRef(attachName);
await cmdAttach(resolvedAttachName, autoRestart, force, geometryNeutral);
await cmdAttach(resolvedAttachName, autoRestart, force);
}
break;
}
Expand Down Expand Up @@ -1553,7 +1545,6 @@ async function cmdAttach(
name: string,
autoRestart = false,
_force = false,
geometryNeutral = false,
): Promise<void> {
// Nesting guard runs in the dispatcher (before name resolution) so the
// user gets the nesting hint even for typo'd refs. cmdAttach itself is
Expand All @@ -1568,33 +1559,17 @@ async function cmdAttach(
}

if (session.status === "running") {
if (geometryNeutral) await requireGeometryNeutralAttach(name);
doAttach(name, geometryNeutral);
doAttach(name);
return;
}

// Dead session — show last lines and offer to restart
await handleDeadSession(session, autoRestart, geometryNeutral);
}

async function requireGeometryNeutralAttach(name: string): Promise<void> {
let supported = false;
try {
const stats = await queryStats(name);
supported = stats.capabilities?.geometryNeutralAttach === true;
} catch {}
if (!supported) {
console.error(
`Session "${name}" does not support --no-resize. Upgrade its pty daemon or attach without the flag.`,
);
process.exit(1);
}
await handleDeadSession(session, autoRestart);
}

async function handleDeadSession(
session: SessionInfo,
autoRestart = false,
geometryNeutral = false,
): Promise<void> {
const meta = session.metadata;
if (!meta) {
Expand Down Expand Up @@ -1636,14 +1611,12 @@ async function handleDeadSession(
scrubEnv: RESTART_SCRUBBED_ENV,
});
console.log(`Session "${session.name}" restarted.`);
if (geometryNeutral) await requireGeometryNeutralAttach(session.name);
doAttach(session.name, geometryNeutral);
doAttach(session.name);
}

function doAttach(name: string, geometryNeutral = false): void {
function doAttach(name: string): void {
attach({
name,
geometryNeutral,
onDetach: () => process.exit(0),
onExit: (code) => process.exit(code),
});
Expand Down
10 changes: 2 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,7 @@ export interface StatsResult {
total: number;
attached: number;
readOnly: number;
geometryNeutral?: number;
};
capabilities?: { geometryNeutralAttach?: boolean };
modes: {
sgrMouse: boolean;
cursorHidden: boolean;
Expand Down Expand Up @@ -377,10 +375,6 @@ export function queryStats(name: string, timeoutMs = 2000): Promise<StatsResult>

export interface AttachOptions {
name: string;
/** Receive output and send input without participating in PTY geometry or
* attach-time redraw nudges. Callers must first verify the daemon advertises
* `capabilities.geometryNeutralAttach` in stats. */
geometryNeutral?: boolean;
onExit?: (code: number) => void;
onDetach?: () => void;
/** Attach over this ALREADY-CONNECTED socket instead of dialing the local
Expand Down Expand Up @@ -497,7 +491,7 @@ export function attach(options: AttachOptions): void {
stdin.on("data", stdinDataHandler);
// Explicitly resume stdin — see the note on readline leaving flowing=false.
stdin.resume();
if (stdout instanceof tty.WriteStream && !options.geometryNeutral) {
if (stdout instanceof tty.WriteStream) {
resizeHandler = () => { try { socket.write(encodeResize(stdout.rows, stdout.columns)); } catch {} };
stdout.on("resize", resizeHandler);
}
Expand All @@ -507,7 +501,7 @@ export function attach(options: AttachOptions): void {
enterRawMode();
const rows = (stdout as tty.WriteStream).rows ?? 24;
const cols = (stdout as tty.WriteStream).columns ?? 80;
try { socket.write(encodeAttach(rows, cols, options.geometryNeutral === true)); } catch {}
try { socket.write(encodeAttach(rows, cols)); } catch {}
wireInput();
}

Expand Down
1 change: 0 additions & 1 deletion src/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ const COMMANDS: readonly CommandSpec[] = [
dynamic: "sessions",
flags: [
{ name: "auto-restart", short: "r", desc: "Auto-restart if the session is exited" },
{ name: "no-resize", desc: "Preserve shared PTY geometry" },
{ name: "force", desc: "Attach even from inside another pty" },
{ name: "remote", desc: "Attach a session on a fabric peer" },
],
Expand Down
20 changes: 2 additions & 18 deletions src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,29 +51,13 @@ export function encodeData(data: string): Buffer {
return encodePacket(MessageType.DATA, Buffer.from(data));
}

/** Optional ATTACH flag: this interactive client receives output and may send
* input, but does not participate in PTY size negotiation or redraw nudges. */
export const ATTACH_FLAG_GEOMETRY_NEUTRAL = 0x01;

export function encodeAttach(
rows: number,
cols: number,
geometryNeutral = false,
): Buffer {
// Keep the legacy frame byte-for-byte identical. The optional flag byte is
// appended only when needed; older servers already ignore bytes after the
// first four size bytes.
const payload = Buffer.alloc(geometryNeutral ? 5 : 4);
export function encodeAttach(rows: number, cols: number): Buffer {
const payload = Buffer.alloc(4);
payload.writeUInt16BE(rows, 0);
payload.writeUInt16BE(cols, 2);
if (geometryNeutral) payload.writeUInt8(ATTACH_FLAG_GEOMETRY_NEUTRAL, 4);
return encodePacket(MessageType.ATTACH, payload);
}

export function decodeAttachFlags(payload: Buffer): number {
return payload.length >= 5 ? payload.readUInt8(4) : 0;
}

export function encodeDetach(): Buffer {
return encodePacket(MessageType.DETACH, Buffer.alloc(0));
}
Expand Down
31 changes: 12 additions & 19 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import {
encodeScreen,
encodeStatusResponse,
decodeSize,
decodeAttachFlags,
ATTACH_FLAG_GEOMETRY_NEUTRAL,
} from "./protocol.ts";
import {
getSocketPath,
Expand All @@ -39,7 +37,6 @@ interface Client {
cols: number;
readonly: boolean;
attachSeq: number;
geometryNeutral: boolean;
}

export interface ServerOptions {
Expand Down Expand Up @@ -586,7 +583,6 @@ export class PtyServer {
cols: this.terminal.cols,
readonly: false,
attachSeq: 0,
geometryNeutral: false,
};
this.clients.set(socket, client);

Expand All @@ -606,8 +602,10 @@ export class PtyServer {
case MessageType.ATTACH: {
if (packet.payload.length < 4) break;
const size = decodeSize(packet.payload);
client.geometryNeutral =
(decodeAttachFlags(packet.payload) & ATTACH_FLAG_GEOMETRY_NEUTRAL) !== 0;
// Read before negotiateSize(): a smaller client shrinks the session
// to its own size, which would then look like it had matched.
const sizeMatched =
size.rows === this.terminal.rows && size.cols === this.terminal.cols;
client.rows = size.rows;
client.cols = size.cols;
client.attachSeq = ++this.attachCounter;
Expand Down Expand Up @@ -640,9 +638,12 @@ export class PtyServer {
// drew (e.g., background fills in ratatui). Nudge the child
// with a SIGWINCH so it does a fresh full redraw, whose DATA
// overwrites any serialize artifacts on the client.
// A geometry-neutral viewer accepts the serialized snapshot as
// is: nudging would perturb the shared child it came to observe.
if (!client.geometryNeutral) this.nudgeRedraw();
//
// Skipped when the client attached at the size the session
// already has: the child is drawn for that geometry, so the
// nudge buys nothing and wakes an otherwise idle process every
// time someone connects.
if (!sizeMatched) this.nudgeRedraw();
}
};

Expand Down Expand Up @@ -754,13 +755,9 @@ export class PtyServer {

let attached = 0;
let readOnly = 0;
let geometryNeutral = 0;
for (const c of this.clients.values()) {
if (c.readonly) readOnly++;
else if (c.attachSeq > 0) {
attached++;
if (c.geometryNeutral) geometryNeutral++;
}
else if (c.attachSeq > 0) attached++;
}

const createdAt = meta?.createdAt ?? null;
Expand Down Expand Up @@ -795,10 +792,6 @@ export class PtyServer {
total: attached + readOnly,
attached,
readOnly,
geometryNeutral,
},
capabilities: {
geometryNeutralAttach: true,
},
modes: {
sgrMouse: this.sgrMouseMode,
Expand All @@ -818,7 +811,7 @@ export class PtyServer {
let cols = 0;

for (const client of this.clients.values()) {
if (!client.readonly && client.attachSeq > 0 && !client.geometryNeutral) {
if (!client.readonly && client.attachSeq > 0) {
rows = rows === 0 ? client.rows : Math.min(rows, client.rows);
cols = cols === 0 ? client.cols : Math.min(cols, client.cols);
}
Expand Down
Loading
Loading