@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 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
@@ -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.2";
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, forward raw input to the pty. the session can exit
760
- // between attach and this write (write() throws once exited); swallow
761
- // so a late keystroke can't crash the daemon's data handler.
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
- try { attached.write(data.toString()); } catch {}
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
- conn.write(JSON.stringify({ cmd: "attach", name }) + "\n");
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 client terminal to match session
1485
- if (ack.cols && ack.rows) {
1486
- // CSI 8 ; rows ; cols t = resize terminal window
1487
- process.stdout.write(`\x1b[8;${ack.rows};${ack.cols}t`);
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.2",
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.2",
30
- "@mentiko/pty-mgr-linux-arm64": "1.4.2",
31
- "@mentiko/pty-mgr-darwin-x64": "1.4.2",
32
- "@mentiko/pty-mgr-darwin-arm64": "1.4.2"
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"