@mentiko/pty-mgr 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/lib/pty-manager.mjs +102 -12
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.3 - 2026-07-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `p attach` now sizes the session to the attaching client's terminal instead of
|
|
8
|
+
resizing the client to the session. The old app-driven CSI-8 resize is ignored
|
|
9
|
+
inside tmux/iTerm panes (or resizes the whole window), so a session viewed in a
|
|
10
|
+
smaller pane kept its default winsize — which made a full-frame TUI (e.g. Claude
|
|
11
|
+
Code) flicker its status line and pushed its bottom row off-screen. The client
|
|
12
|
+
now sends its size in the attach request and the daemon resizes the session
|
|
13
|
+
(SIGWINCHing the child) to match.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Live-resize while attached: when the client's terminal changes size, the
|
|
18
|
+
session (and its child) resize to follow. The client sends an out-of-band APC
|
|
19
|
+
control frame that the daemon strips from the raw input stream, so it never
|
|
20
|
+
reaches the pty as keystrokes.
|
|
21
|
+
|
|
22
|
+
## 1.4.2 - 2026-07-07
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Internal refactor of `lib/pty-manager.mjs`: the two near-identical socket
|
|
27
|
+
clients collapse into one `requestSocket`, and the terminal-size clamps,
|
|
28
|
+
`--log` wiring, telegram send, capture-stability check, and transcript
|
|
29
|
+
listing move to shared helpers. No behavior or public API change.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Hardened error handling on three paths that could take the daemon down: the
|
|
34
|
+
socket client now turns a malformed or truncated reply into a rejection
|
|
35
|
+
instead of an uncaught throw in its data handler; the log write stream gets an
|
|
36
|
+
`error` listener (a disk-full / permission error drops logging instead of
|
|
37
|
+
crashing); and attach-mode input is guarded so a keystroke sent to a
|
|
38
|
+
just-exited session no longer throws in the socket handler.
|
|
39
|
+
|
|
40
|
+
## 1.4.1 - 2026-07-07
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `p attach` replays the session's full terminal state via the xterm
|
|
45
|
+
serialize-addon (scrollback, colors, cursor, modes). Normal-buffer sessions
|
|
46
|
+
(a shell, Claude Code, …) are no longer forced into the alternate screen —
|
|
47
|
+
which had discarded scrollback — so history stays scrollable in the client.
|
|
48
|
+
Alt-screen TUIs still get the alt-screen switch, and the client pops back to
|
|
49
|
+
its normal screen on detach.
|
|
50
|
+
|
|
51
|
+
## 1.4.0 - 2026-07-07
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `p attach` replays full scrollback history on connect, not just the visible
|
|
56
|
+
screen.
|
|
57
|
+
- Layered flow config resolution: flows merge from the packaged defaults, the
|
|
58
|
+
XDG user config, and a project `pty-mgr.config.json` (project overrides user
|
|
59
|
+
overrides default). `p flow list` tags each flow with its source layer,
|
|
60
|
+
`p flow new [--global]` scaffolds a project (or user) flow, and `p open
|
|
61
|
+
config` opens the config directory.
|
|
62
|
+
|
|
3
63
|
## 1.3.1 - 2026-07-02
|
|
4
64
|
|
|
5
65
|
### Fixed
|
package/lib/pty-manager.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { fileURLToPath } from "node:url";
|
|
|
42
42
|
import { spawn as spawnChild } from "node:child_process";
|
|
43
43
|
|
|
44
44
|
// kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
|
|
45
|
-
export const VERSION = "1.4.
|
|
45
|
+
export const VERSION = "1.4.3";
|
|
46
46
|
import xterm from "@xterm/headless";
|
|
47
47
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
48
48
|
|
|
@@ -700,6 +700,54 @@ function socketPath(name) {
|
|
|
700
700
|
const { daemon: DAEMON_NAME, args: CLI_ARGS } = splitDaemonArgs(process.argv.slice(2));
|
|
701
701
|
const SOCKET_PATH = socketPath(DAEMON_NAME);
|
|
702
702
|
|
|
703
|
+
// ── attach input: out-of-band resize control frames ──────────────────
|
|
704
|
+
// While attached, the socket carries raw keystrokes. A client signals a live
|
|
705
|
+
// terminal resize with an APC control frame the daemon pulls out of the stream
|
|
706
|
+
// instead of forwarding to the pty: ESC _ ptymgr:resize:<cols>:<rows> ESC \
|
|
707
|
+
// APC (ESC _) is ignored by terminals and is never produced by a keyboard, so
|
|
708
|
+
// it cannot collide with real input.
|
|
709
|
+
const RESIZE_PREFIX = Buffer.from("\x1b_ptymgr:resize:");
|
|
710
|
+
const RESIZE_ST = Buffer.from("\x1b\\");
|
|
711
|
+
|
|
712
|
+
// longest k (>=1) where buf's last k bytes equal prefix's first k bytes.
|
|
713
|
+
function suffixPrefixOverlap(buf, prefix) {
|
|
714
|
+
for (let k = Math.min(buf.length, prefix.length - 1); k > 0; k--) {
|
|
715
|
+
if (buf.subarray(buf.length - k).equals(prefix.subarray(0, k))) return k;
|
|
716
|
+
}
|
|
717
|
+
return 0;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Split raw attach input into pty keystrokes and resize frames. Returns
|
|
721
|
+
// { forward: Buffer for the pty, resizes: [{cols,rows}], rest: Buffer to
|
|
722
|
+
// prepend to the next chunk }. `rest` holds an incomplete frame, or a trailing
|
|
723
|
+
// "ESC _…" that may begin one, so a frame split across reads reassembles. A
|
|
724
|
+
// lone trailing ESC is NOT held (that's the Escape key or a CSI start) so it
|
|
725
|
+
// forwards immediately.
|
|
726
|
+
export function parseAttachInput(buf) {
|
|
727
|
+
const forward = [];
|
|
728
|
+
const resizes = [];
|
|
729
|
+
let i = 0;
|
|
730
|
+
while (i < buf.length) {
|
|
731
|
+
const start = buf.indexOf(RESIZE_PREFIX, i);
|
|
732
|
+
if (start === -1) break;
|
|
733
|
+
if (start > i) forward.push(buf.subarray(i, start));
|
|
734
|
+
const st = buf.indexOf(RESIZE_ST, start + RESIZE_PREFIX.length);
|
|
735
|
+
if (st === -1) {
|
|
736
|
+
// incomplete frame: forward what precedes it, hold the rest
|
|
737
|
+
return { forward: Buffer.concat(forward), resizes, rest: buf.subarray(start) };
|
|
738
|
+
}
|
|
739
|
+
const body = buf.subarray(start + RESIZE_PREFIX.length, st).toString("latin1");
|
|
740
|
+
const m = body.match(/^(\d+):(\d+)$/);
|
|
741
|
+
if (m) resizes.push({ cols: Number(m[1]), rows: Number(m[2]) });
|
|
742
|
+
i = st + RESIZE_ST.length;
|
|
743
|
+
}
|
|
744
|
+
const tail = buf.subarray(i);
|
|
745
|
+
let keep = suffixPrefixOverlap(tail, RESIZE_PREFIX);
|
|
746
|
+
if (keep < 2) keep = 0; // never hold a lone ESC (Escape key / CSI start)
|
|
747
|
+
if (tail.length > keep) forward.push(tail.subarray(0, tail.length - keep));
|
|
748
|
+
return { forward: Buffer.concat(forward), resizes, rest: tail.subarray(tail.length - keep) };
|
|
749
|
+
}
|
|
750
|
+
|
|
703
751
|
/**
|
|
704
752
|
* daemon: long-running process that holds all sessions.
|
|
705
753
|
* clients connect via unix socket, send JSON commands, get JSON responses.
|
|
@@ -746,6 +794,7 @@ function startDaemon() {
|
|
|
746
794
|
const server = createServer((conn) => {
|
|
747
795
|
let buf = "";
|
|
748
796
|
let attached = false; // true when in attach streaming mode
|
|
797
|
+
let attachInBuf = Buffer.alloc(0); // carries a split resize frame across reads
|
|
749
798
|
|
|
750
799
|
// suppress connection errors (client disconnect, etc.)
|
|
751
800
|
conn.on("error", () => {});
|
|
@@ -756,11 +805,21 @@ function startDaemon() {
|
|
|
756
805
|
});
|
|
757
806
|
|
|
758
807
|
conn.on("data", async (data) => {
|
|
759
|
-
// in attach mode
|
|
760
|
-
//
|
|
761
|
-
//
|
|
808
|
+
// in attach mode: pull out any resize control frames (applied to the
|
|
809
|
+
// session), forward the rest as raw keystrokes to the pty. the session
|
|
810
|
+
// can exit between attach and this write (write() throws once exited);
|
|
811
|
+
// swallow so a late keystroke can't crash the daemon's data handler.
|
|
762
812
|
if (attached) {
|
|
763
|
-
|
|
813
|
+
const { forward, resizes, rest } = parseAttachInput(
|
|
814
|
+
attachInBuf.length ? Buffer.concat([attachInBuf, data]) : data
|
|
815
|
+
);
|
|
816
|
+
attachInBuf = rest;
|
|
817
|
+
for (const { cols, rows } of resizes) {
|
|
818
|
+
try { attached.resize(cols, rows); } catch {}
|
|
819
|
+
}
|
|
820
|
+
if (forward.length) {
|
|
821
|
+
try { attached.write(forward.toString()); } catch {}
|
|
822
|
+
}
|
|
764
823
|
return;
|
|
765
824
|
}
|
|
766
825
|
|
|
@@ -781,6 +840,15 @@ function startDaemon() {
|
|
|
781
840
|
// attach is special: switches to streaming mode
|
|
782
841
|
if (req.cmd === "attach") {
|
|
783
842
|
const session = mgr.get(req.name);
|
|
843
|
+
// size the session to the attaching client's terminal so the
|
|
844
|
+
// child's winsize matches what the user actually sees. A pane
|
|
845
|
+
// (tmux/iTerm split) ignores an app's own CSI-8 window resize --
|
|
846
|
+
// and honoring it would resize the user's whole window -- so we
|
|
847
|
+
// resize the session TO the client, the way tmux/ssh do. resize()
|
|
848
|
+
// SIGWINCHes the child, which repaints at the new size; a mismatch
|
|
849
|
+
// here is what makes a full-frame TUI's status line flicker and
|
|
850
|
+
// pushes its bottom row off-screen. No size sent => leave as-is.
|
|
851
|
+
if (req.cols && req.rows) session.resize(req.cols, req.rows);
|
|
784
852
|
const cols = session.terminal.cols;
|
|
785
853
|
const rows = session.terminal.rows;
|
|
786
854
|
const alt = session.terminal.buffer.active.type === "alternate";
|
|
@@ -819,6 +887,7 @@ function startDaemon() {
|
|
|
819
887
|
|
|
820
888
|
// forward client input to pty
|
|
821
889
|
attached = session;
|
|
890
|
+
attachInBuf = Buffer.alloc(0);
|
|
822
891
|
|
|
823
892
|
// cleanup on disconnect
|
|
824
893
|
conn.on("close", () => {
|
|
@@ -1437,11 +1506,19 @@ function attachToSession(name) {
|
|
|
1437
1506
|
});
|
|
1438
1507
|
|
|
1439
1508
|
conn.on("connect", () => {
|
|
1440
|
-
// send attach request
|
|
1441
|
-
|
|
1509
|
+
// send attach request with our real terminal size so the daemon can size
|
|
1510
|
+
// the session (and its child) to us. undefined when stdout isn't a TTY;
|
|
1511
|
+
// the daemon then leaves the session size unchanged.
|
|
1512
|
+
conn.write(JSON.stringify({
|
|
1513
|
+
cmd: "attach",
|
|
1514
|
+
name,
|
|
1515
|
+
cols: process.stdout.columns,
|
|
1516
|
+
rows: process.stdout.rows,
|
|
1517
|
+
}) + "\n");
|
|
1442
1518
|
|
|
1443
1519
|
let gotAck = false;
|
|
1444
1520
|
let headerBuf = "";
|
|
1521
|
+
let onStdoutResize = null;
|
|
1445
1522
|
|
|
1446
1523
|
// last-seen alt-screen state: detach pops the client back to its
|
|
1447
1524
|
// normal screen only if the session left it in the alternate buffer
|
|
@@ -1481,11 +1558,11 @@ function attachToSession(name) {
|
|
|
1481
1558
|
gotAck = true;
|
|
1482
1559
|
altActive = !!ack.alt;
|
|
1483
1560
|
|
|
1484
|
-
// resize
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1561
|
+
// NOTE: we do NOT resize our own terminal to the session. The daemon
|
|
1562
|
+
// has already sized the session to us (we sent our size in the attach
|
|
1563
|
+
// request). An app-driven CSI-8 window resize is ignored inside
|
|
1564
|
+
// tmux/iTerm panes and, where honored, resizes the user's entire
|
|
1565
|
+
// window -- which is what caused the flicker and the missing bottom row.
|
|
1489
1566
|
|
|
1490
1567
|
// put terminal in raw mode
|
|
1491
1568
|
process.stdin.setRawMode(true);
|
|
@@ -1509,6 +1586,15 @@ function attachToSession(name) {
|
|
|
1509
1586
|
conn.write(key);
|
|
1510
1587
|
});
|
|
1511
1588
|
|
|
1589
|
+
// live-resize: when our terminal changes size, tell the daemon so it
|
|
1590
|
+
// resizes the session (and its child's winsize) to match. Sent as an
|
|
1591
|
+
// out-of-band APC frame the daemon strips from the input stream.
|
|
1592
|
+
onStdoutResize = () => {
|
|
1593
|
+
const c = process.stdout.columns, r = process.stdout.rows;
|
|
1594
|
+
if (c && r) { try { conn.write(`\x1b_ptymgr:resize:${c}:${r}\x1b\\`); } catch {} }
|
|
1595
|
+
};
|
|
1596
|
+
process.stdout.on("resize", onStdoutResize);
|
|
1597
|
+
|
|
1512
1598
|
return;
|
|
1513
1599
|
}
|
|
1514
1600
|
|
|
@@ -1525,6 +1611,10 @@ function attachToSession(name) {
|
|
|
1525
1611
|
function detach() {
|
|
1526
1612
|
if (detached || failed) return;
|
|
1527
1613
|
detached = true;
|
|
1614
|
+
if (onStdoutResize) {
|
|
1615
|
+
process.stdout.removeListener("resize", onStdoutResize);
|
|
1616
|
+
onStdoutResize = null;
|
|
1617
|
+
}
|
|
1528
1618
|
if (process.stdin.isRaw) {
|
|
1529
1619
|
process.stdin.setRawMode(false);
|
|
1530
1620
|
process.stdin.pause();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentiko/pty-mgr",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "PTY session manager with terminal emulation for programmatic session control.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/pty-manager.mjs",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"@xterm/headless": "^6.0.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"@mentiko/pty-mgr-linux-x64": "1.4.
|
|
30
|
-
"@mentiko/pty-mgr-linux-arm64": "1.4.
|
|
31
|
-
"@mentiko/pty-mgr-darwin-x64": "1.4.
|
|
32
|
-
"@mentiko/pty-mgr-darwin-arm64": "1.4.
|
|
29
|
+
"@mentiko/pty-mgr-linux-x64": "1.4.3",
|
|
30
|
+
"@mentiko/pty-mgr-linux-arm64": "1.4.3",
|
|
31
|
+
"@mentiko/pty-mgr-darwin-x64": "1.4.3",
|
|
32
|
+
"@mentiko/pty-mgr-darwin-arm64": "1.4.3"
|
|
33
33
|
},
|
|
34
34
|
"engines": {
|
|
35
35
|
"bun": ">=1.0"
|