@mentiko/pty-mgr 1.4.2 → 1.5.0

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,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0 - 2026-07-18
4
+
5
+ ### Added
6
+
7
+ - `p daemons` lists every running daemon on the machine — one line per daemon
8
+ with pid, uptime, session counts, and cwd, in the same style as `p list`. The
9
+ currently-selected daemon (`@name` / `$PTY_DAEMON`) is marked with a leading
10
+ `*`; a socket that no longer answers is shown as `(stale)` and one with an
11
+ empty name as `(unnamed)`. It is read-only — unlike `p stop all` it never
12
+ removes stale sockets. Both commands now share one socket-enumeration helper.
13
+
14
+ ### Fixed
15
+
16
+ - The installer no longer reports success while an older `pty-mgr` shadows the
17
+ freshly installed one. `install.sh` used to print `PATH is set.` once
18
+ `~/.pty-mgr/bin` was on `PATH`, which said nothing about *which* copy answers:
19
+ a stale binary earlier in `PATH` (e.g. a hand-placed `/usr/local/bin/pty-mgr`,
20
+ which sits on line 1 of macOS `/etc/paths`) would win silently. The installer
21
+ now scans every `PATH` entry after installing and prints any other `pty-mgr`
22
+ or `p` it finds, with an exact `rm` line to remove them.
23
+
24
+ ## 1.4.3 - 2026-07-07
25
+
26
+ ### Fixed
27
+
28
+ - `p attach` now sizes the session to the attaching client's terminal instead of
29
+ resizing the client to the session. The old app-driven CSI-8 resize is ignored
30
+ inside tmux/iTerm panes (or resizes the whole window), so a session viewed in a
31
+ smaller pane kept its default winsize — which made a full-frame TUI (e.g. Claude
32
+ Code) flicker its status line and pushed its bottom row off-screen. The client
33
+ now sends its size in the attach request and the daemon resizes the session
34
+ (SIGWINCHing the child) to match.
35
+
36
+ ### Added
37
+
38
+ - Live-resize while attached: when the client's terminal changes size, the
39
+ session (and its child) resize to follow. The client sends an out-of-band APC
40
+ control frame that the daemon strips from the raw input stream, so it never
41
+ reaches the pty as keystrokes.
42
+
43
+ ## 1.4.2 - 2026-07-07
44
+
45
+ ### Changed
46
+
47
+ - Internal refactor of `lib/pty-manager.mjs`: the two near-identical socket
48
+ clients collapse into one `requestSocket`, and the terminal-size clamps,
49
+ `--log` wiring, telegram send, capture-stability check, and transcript
50
+ listing move to shared helpers. No behavior or public API change.
51
+
52
+ ### Fixed
53
+
54
+ - Hardened error handling on three paths that could take the daemon down: the
55
+ socket client now turns a malformed or truncated reply into a rejection
56
+ instead of an uncaught throw in its data handler; the log write stream gets an
57
+ `error` listener (a disk-full / permission error drops logging instead of
58
+ crashing); and attach-mode input is guarded so a keystroke sent to a
59
+ just-exited session no longer throws in the socket handler.
60
+
61
+ ## 1.4.1 - 2026-07-07
62
+
63
+ ### Fixed
64
+
65
+ - `p attach` replays the session's full terminal state via the xterm
66
+ serialize-addon (scrollback, colors, cursor, modes). Normal-buffer sessions
67
+ (a shell, Claude Code, …) are no longer forced into the alternate screen —
68
+ which had discarded scrollback — so history stays scrollable in the client.
69
+ Alt-screen TUIs still get the alt-screen switch, and the client pops back to
70
+ its normal screen on detach.
71
+
72
+ ## 1.4.0 - 2026-07-07
73
+
74
+ ### Added
75
+
76
+ - `p attach` replays full scrollback history on connect, not just the visible
77
+ screen.
78
+ - Layered flow config resolution: flows merge from the packaged defaults, the
79
+ XDG user config, and a project `pty-mgr.config.json` (project overrides user
80
+ overrides default). `p flow list` tags each flow with its source layer,
81
+ `p flow new [--global]` scaffolds a project (or user) flow, and `p open
82
+ config` opens the config directory.
83
+
3
84
  ## 1.3.1 - 2026-07-02
4
85
 
5
86
  ### 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.5.0";
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", () => {
@@ -1410,6 +1479,32 @@ function sendCommandTo(sock, req) {
1410
1479
  return requestSocket(sock, req, "daemon not running");
1411
1480
  }
1412
1481
 
1482
+ // enumerate every daemon socket: ~/.pty-manager/*.sock plus legacy
1483
+ // /tmp/pty-manager-*.sock. returns [{ name, sockFile }]. shared by `stop all`
1484
+ // and `daemons` so both see the same set. presence of a .sock file does not
1485
+ // prove the daemon is alive -- the caller probes it.
1486
+ function listDaemonSockets() {
1487
+ const found = [];
1488
+ const dir = join(homedir(), ".pty-manager");
1489
+ try {
1490
+ for (const f of readdirSync(dir)) {
1491
+ if (f.endsWith(".sock")) {
1492
+ found.push({ name: f.replace(/\.sock$/, ""), sockFile: join(dir, f) });
1493
+ }
1494
+ }
1495
+ } catch { /* dir doesn't exist yet */ }
1496
+ try {
1497
+ const tmp = tmpdir();
1498
+ for (const f of readdirSync(tmp)) {
1499
+ if (f.startsWith("pty-manager-") && f.endsWith(".sock")) {
1500
+ const name = f.replace(/^pty-manager-/, "").replace(/\.sock$/, "");
1501
+ found.push({ name, sockFile: join(tmp, f) });
1502
+ }
1503
+ }
1504
+ } catch { /* tmp unreadable */ }
1505
+ return found;
1506
+ }
1507
+
1413
1508
  // send to the daemon selected by @name / --daemon / $PTY_DAEMON.
1414
1509
  function sendCommand(req) {
1415
1510
  return requestSocket(SOCKET_PATH, req, "daemon not running. start with: pty-mgr daemon");
@@ -1437,11 +1532,19 @@ function attachToSession(name) {
1437
1532
  });
1438
1533
 
1439
1534
  conn.on("connect", () => {
1440
- // send attach request
1441
- conn.write(JSON.stringify({ cmd: "attach", name }) + "\n");
1535
+ // send attach request with our real terminal size so the daemon can size
1536
+ // the session (and its child) to us. undefined when stdout isn't a TTY;
1537
+ // the daemon then leaves the session size unchanged.
1538
+ conn.write(JSON.stringify({
1539
+ cmd: "attach",
1540
+ name,
1541
+ cols: process.stdout.columns,
1542
+ rows: process.stdout.rows,
1543
+ }) + "\n");
1442
1544
 
1443
1545
  let gotAck = false;
1444
1546
  let headerBuf = "";
1547
+ let onStdoutResize = null;
1445
1548
 
1446
1549
  // last-seen alt-screen state: detach pops the client back to its
1447
1550
  // normal screen only if the session left it in the alternate buffer
@@ -1481,11 +1584,11 @@ function attachToSession(name) {
1481
1584
  gotAck = true;
1482
1585
  altActive = !!ack.alt;
1483
1586
 
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
- }
1587
+ // NOTE: we do NOT resize our own terminal to the session. The daemon
1588
+ // has already sized the session to us (we sent our size in the attach
1589
+ // request). An app-driven CSI-8 window resize is ignored inside
1590
+ // tmux/iTerm panes and, where honored, resizes the user's entire
1591
+ // window -- which is what caused the flicker and the missing bottom row.
1489
1592
 
1490
1593
  // put terminal in raw mode
1491
1594
  process.stdin.setRawMode(true);
@@ -1509,6 +1612,15 @@ function attachToSession(name) {
1509
1612
  conn.write(key);
1510
1613
  });
1511
1614
 
1615
+ // live-resize: when our terminal changes size, tell the daemon so it
1616
+ // resizes the session (and its child's winsize) to match. Sent as an
1617
+ // out-of-band APC frame the daemon strips from the input stream.
1618
+ onStdoutResize = () => {
1619
+ const c = process.stdout.columns, r = process.stdout.rows;
1620
+ if (c && r) { try { conn.write(`\x1b_ptymgr:resize:${c}:${r}\x1b\\`); } catch {} }
1621
+ };
1622
+ process.stdout.on("resize", onStdoutResize);
1623
+
1512
1624
  return;
1513
1625
  }
1514
1626
 
@@ -1525,6 +1637,10 @@ function attachToSession(name) {
1525
1637
  function detach() {
1526
1638
  if (detached || failed) return;
1527
1639
  detached = true;
1640
+ if (onStdoutResize) {
1641
+ process.stdout.removeListener("resize", onStdoutResize);
1642
+ onStdoutResize = null;
1643
+ }
1528
1644
  if (process.stdin.isRaw) {
1529
1645
  process.stdin.setRawMode(false);
1530
1646
  process.stdin.pause();
@@ -1552,6 +1668,7 @@ const USAGE = `pty-mgr - PTY session manager
1552
1668
  usage:
1553
1669
  p daemon start daemon (background: &)
1554
1670
  p daemon @myproject named daemon (isolated sessions)
1671
+ p daemons list all running daemons (* = current)
1555
1672
  p status daemon info + config
1556
1673
  p config show current config
1557
1674
  p config screen 100x50 set default terminal size
@@ -2969,32 +3086,13 @@ async function cli() {
2969
3086
  const target = args[0]; // "all" or undefined (= current daemon)
2970
3087
  if (target === "all") {
2971
3088
  // find all pty-manager sockets and shut them down
2972
- const dir = join(homedir(), ".pty-manager");
2973
- let socks = [];
2974
- try {
2975
- socks = readdirSync(dir).filter((f) => f.endsWith(".sock"));
2976
- } catch { /* dir doesn't exist */ }
2977
- // also check legacy /tmp/ location for old sockets
2978
- try {
2979
- const tmp = tmpdir();
2980
- const legacy = readdirSync(tmp).filter((f) => f.startsWith("pty-manager-") && f.endsWith(".sock"));
2981
- for (const s of legacy) socks.push("__legacy__/" + s);
2982
- } catch {}
2983
- if (socks.length === 0) {
3089
+ const daemons = listDaemonSockets();
3090
+ if (daemons.length === 0) {
2984
3091
  console.log("no daemons running");
2985
3092
  return;
2986
3093
  }
2987
3094
  const stopped = [];
2988
- for (const sock of socks) {
2989
- let sockFile, name;
2990
- if (sock.startsWith("__legacy__/")) {
2991
- const legacyName = sock.replace("__legacy__/", "");
2992
- sockFile = join(tmpdir(), legacyName);
2993
- name = legacyName.replace("pty-manager-", "").replace(".sock", "");
2994
- } else {
2995
- sockFile = join(dir, sock);
2996
- name = sock.replace(".sock", "");
2997
- }
3095
+ for (const { name, sockFile } of daemons) {
2998
3096
  try {
2999
3097
  const res = await sendCommandTo(sockFile, { cmd: "shutdown" });
3000
3098
  if (res.ok) stopped.push(name);
@@ -3017,6 +3115,36 @@ async function cli() {
3017
3115
  return;
3018
3116
  }
3019
3117
 
3118
+ if (command === "daemons") {
3119
+ // read-only listing of every daemon socket. probes each for live status;
3120
+ // unlike `stop all` it never removes stale sockets. one line per daemon,
3121
+ // matching the `list` house style. current daemon (@name/$PTY_DAEMON) is
3122
+ // marked with a leading '*'.
3123
+ const daemons = listDaemonSockets();
3124
+ if (daemons.length === 0) {
3125
+ console.log("no daemons running");
3126
+ return;
3127
+ }
3128
+ for (const { name, sockFile } of daemons) {
3129
+ const marker = sockFile === SOCKET_PATH ? "*" : " ";
3130
+ let st = null;
3131
+ try {
3132
+ const res = await sendCommandTo(sockFile, { cmd: "status" });
3133
+ if (res.ok) st = res.status;
3134
+ } catch { /* stale: file exists but nothing is listening */ }
3135
+ const label = name || "(unnamed)";
3136
+ if (st) {
3137
+ const s = st.sessions;
3138
+ let line = `${marker} ${label} pid=${st.pid} up=${st.uptime} ${s.alive}/${s.total} sessions`;
3139
+ if (st.cwd) line += ` ${st.cwd}`; // older daemons may not report cwd
3140
+ console.log(line);
3141
+ } else {
3142
+ console.log(`${marker} ${label} (stale)`);
3143
+ }
3144
+ }
3145
+ return;
3146
+ }
3147
+
3020
3148
  if (command === "attach") {
3021
3149
  const name = args[0];
3022
3150
  if (!name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentiko/pty-mgr",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
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.5.0",
30
+ "@mentiko/pty-mgr-linux-arm64": "1.5.0",
31
+ "@mentiko/pty-mgr-darwin-x64": "1.5.0",
32
+ "@mentiko/pty-mgr-darwin-arm64": "1.5.0"
33
33
  },
34
34
  "engines": {
35
35
  "bun": ">=1.0"