@mentiko/pty-mgr 1.4.1 → 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
@@ -1,30 +1,27 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * pty-manager.mjs - PTY-based agent session manager
3
+ * pty-manager.mjs (v2) - PTY-based agent session manager
4
4
  *
5
5
  * Uses:
6
6
  * - Bun.spawn native PTY support (no python, no native addons)
7
7
  * - @xterm/headless terminal emulator (parses escape codes into screen)
8
8
  *
9
- * capture() returns the actual rendered screen state, not raw output.
9
+ * capture() returns the actual rendered screen state, not raw output:
10
10
  * spinners, progress bars, cursor movements, and TUI redraws are all
11
11
  * resolved into clean text.
12
12
  *
13
- * API:
14
- * mgr.spawn(name, cmd) create session
15
- * mgr.sendKeys(name, text) send keystrokes
16
- * mgr.capture(name, lines) capture rendered screen
17
- * mgr.has(name) check if session exists
18
- * mgr.kill(name) kill session
19
- * mgr.list() list sessions
20
- * mgr.pid(name) get child process pid
13
+ * v2 refactor: deduplicated the socket client, terminal-size clamps,
14
+ * capture-stability check, `--log` wiring, telegram send, and transcript
15
+ * listing; hardened error handling on the socket read path, the attach
16
+ * input path, and the log write stream. Behavior and the public API are
17
+ * unchanged (see the test suite).
21
18
  *
22
- * Usage:
23
- * import { PtyManager } from './pty-manager.mjs';
19
+ * Library API:
24
20
  * const mgr = new PtyManager();
25
- * mgr.spawn('agent-1', 'claude', ['--print']);
26
- * mgr.sendKeys('agent-1', 'fix the bug\r');
27
- * console.log(mgr.capture('agent-1', 40));
21
+ * mgr.spawn('agent-1', 'claude', ['--print']); // create session
22
+ * mgr.sendKeys('agent-1', 'fix the bug\r'); // send keystrokes
23
+ * mgr.capture('agent-1', 40); // rendered screen
24
+ * mgr.has(name) / mgr.kill(name) / mgr.list() / mgr.pid(name)
28
25
  */
29
26
 
30
27
  import { EventEmitter } from "node:events";
@@ -39,12 +36,13 @@ import {
39
36
  readdirSync,
40
37
  statSync,
41
38
  writeFileSync,
39
+ appendFileSync,
42
40
  } from "node:fs";
43
41
  import { fileURLToPath } from "node:url";
44
42
  import { spawn as spawnChild } from "node:child_process";
45
43
 
46
44
  // kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
47
- export const VERSION = "1.4.1";
45
+ export const VERSION = "1.4.3";
48
46
  import xterm from "@xterm/headless";
49
47
  import { SerializeAddon } from "@xterm/addon-serialize";
50
48
 
@@ -68,6 +66,15 @@ export function validateSessionName(name) {
68
66
  }
69
67
  }
70
68
 
69
+ // terminal-size clamps: keep buffers sane, prevent memory exhaustion from a
70
+ // hostile/typo'd size. shared by PtySession.resize and the daemon spawn/wrap.
71
+ function clampCols(value, fallback) {
72
+ return Math.max(20, Math.min(500, value || fallback));
73
+ }
74
+ function clampRows(value, fallback) {
75
+ return Math.max(5, Math.min(200, value || fallback));
76
+ }
77
+
71
78
  class PtySession {
72
79
  constructor(name, opts = {}) {
73
80
  this.name = name;
@@ -152,16 +159,9 @@ class PtySession {
152
159
  }
153
160
 
154
161
  /**
155
- * capture the rendered screen buffer.
156
- *
157
- * returns what you'd actually see on the terminal right now.
158
- * escape codes, cursor movements, line erases -- all resolved.
159
- * equivalent of reading the full terminal screen buffer.
160
- *
161
- * @param {number} [tailLines] - last N lines (0 or omit = visible screen)
162
- * @param {object} [opts] - options
163
- * @param {boolean} [opts.screen] - only capture visible rows (skip scrollback)
164
- * @returns {string}
162
+ * capture the rendered screen: what you'd see now, with escape codes, cursor
163
+ * moves and line-erases all resolved. tailLines>0 returns the last N lines;
164
+ * opts.screen limits to visible rows (skips scrollback).
165
165
  */
166
166
  capture(tailLines, opts = {}) {
167
167
  const buf = this.terminal.buffer.active;
@@ -187,21 +187,15 @@ class PtySession {
187
187
  return lines.join("\n");
188
188
  }
189
189
 
190
- /**
191
- * capture the rendered screen with ANSI color/attribute escape codes.
192
- * uses @xterm/addon-serialize to preserve colors, bold, etc.
193
- * @returns {string}
194
- */
190
+ /** rendered screen with ANSI color/attribute codes (via addon-serialize). */
195
191
  captureAnsi() {
196
192
  return this._serializer.serialize();
197
193
  }
198
194
 
199
195
  /**
200
- * capture visible content with ANSI colors, trimmed of empty lines.
201
- * reads cells individually to build colored output line by line.
202
- * result starts from line 0 (no leading empty rows).
203
- * @param {number} [tailLines] - last N lines (0 = all content)
204
- * @returns {string}
196
+ * visible content with ANSI colors, trimmed of leading/trailing empty lines.
197
+ * reads cells individually (line by line) so colors/attributes survive.
198
+ * tailLines>0 = last N lines.
205
199
  */
206
200
  captureAnsiCompact(tailLines) {
207
201
  const buf = this.terminal.buffer.active;
@@ -289,14 +283,10 @@ class PtySession {
289
283
  return lines.join("\r\n");
290
284
  }
291
285
 
292
- /**
293
- * resize the PTY and headless terminal.
294
- * @param {number} cols
295
- * @param {number} rows
296
- */
286
+ /** resize the PTY and headless terminal (clamped to sane bounds). */
297
287
  resize(cols, rows) {
298
- cols = Math.max(20, Math.min(500, cols));
299
- rows = Math.max(5, Math.min(200, rows));
288
+ cols = clampCols(cols, cols);
289
+ rows = clampRows(rows, rows);
300
290
  this.terminal.resize(cols, rows);
301
291
  if (this._pty && !this.exited) {
302
292
  try { this._pty.resize(cols, rows); } catch {}
@@ -335,15 +325,10 @@ class PtySession {
335
325
  }
336
326
 
337
327
  /**
338
- * start logging session output to a file.
339
- *
340
- * format:
328
+ * start logging session output to a file. format:
341
329
  * "raw" - raw PTY bytes (escape codes included, replayable)
342
- * "rendered" - clean screen snapshots on each data event
343
- * "jsonl" - timestamped JSON lines { t, type, data }
344
- *
345
- * @param {string} logPath - file path to write to
346
- * @param {string} [format="raw"] - log format
330
+ * "rendered" - clean screen snapshots on each data event
331
+ * "jsonl" - timestamped JSON lines { t, type, data }
347
332
  */
348
333
  startLog(logPath, format = "raw") {
349
334
  if (this._logStream) this.stopLog();
@@ -354,6 +339,10 @@ class PtySession {
354
339
  this._logPath = logPath;
355
340
  this._logFormat = format;
356
341
  this._logStream = createWriteStream(logPath, { flags: "a" });
342
+ // a write error (disk full, perms, unlinked path) emits 'error'; without a
343
+ // listener node throws it as uncaught and takes the daemon down. drop
344
+ // logging on error rather than crash the session.
345
+ this._logStream.on("error", () => { try { this.stopLog(); } catch {} });
357
346
 
358
347
  // write header for jsonl
359
348
  if (format === "jsonl") {
@@ -470,18 +459,9 @@ export class PtyManager {
470
459
  }
471
460
 
472
461
  /**
473
- * spawn a command inside a real PTY with terminal emulation
474
- *
475
- * @param {string} name - unique session name
476
- * @param {string} cmd - command to run
477
- * @param {string[]} [args] - arguments
478
- * @param {object} [opts]
479
- * @param {string} [opts.cwd] - working directory
480
- * @param {object} [opts.env] - extra env vars
481
- * @param {number} [opts.cols] - terminal columns (default 200)
482
- * @param {number} [opts.rows] - terminal rows (default 50)
483
- * @param {number} [opts.scrollback] - scrollback lines (default 5000)
484
- * @returns {string} session name
462
+ * spawn a command inside a real PTY with terminal emulation. opts: cwd, env
463
+ * (extra vars), cols (default 100), rows (default 35), scrollback (5000).
464
+ * returns the session name.
485
465
  */
486
466
  spawn(name, cmd, args = [], opts = {}) {
487
467
  validateSessionName(name);
@@ -720,6 +700,54 @@ function socketPath(name) {
720
700
  const { daemon: DAEMON_NAME, args: CLI_ARGS } = splitDaemonArgs(process.argv.slice(2));
721
701
  const SOCKET_PATH = socketPath(DAEMON_NAME);
722
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
+
723
751
  /**
724
752
  * daemon: long-running process that holds all sessions.
725
753
  * clients connect via unix socket, send JSON commands, get JSON responses.
@@ -766,6 +794,7 @@ function startDaemon() {
766
794
  const server = createServer((conn) => {
767
795
  let buf = "";
768
796
  let attached = false; // true when in attach streaming mode
797
+ let attachInBuf = Buffer.alloc(0); // carries a split resize frame across reads
769
798
 
770
799
  // suppress connection errors (client disconnect, etc.)
771
800
  conn.on("error", () => {});
@@ -776,9 +805,21 @@ function startDaemon() {
776
805
  });
777
806
 
778
807
  conn.on("data", async (data) => {
779
- // in attach mode, forward raw input to the pty
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.
780
812
  if (attached) {
781
- attached.write(data.toString());
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
+ }
782
823
  return;
783
824
  }
784
825
 
@@ -799,6 +840,15 @@ function startDaemon() {
799
840
  // attach is special: switches to streaming mode
800
841
  if (req.cmd === "attach") {
801
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);
802
852
  const cols = session.terminal.cols;
803
853
  const rows = session.terminal.rows;
804
854
  const alt = session.terminal.buffer.active.type === "alternate";
@@ -837,6 +887,7 @@ function startDaemon() {
837
887
 
838
888
  // forward client input to pty
839
889
  attached = session;
890
+ attachInBuf = Buffer.alloc(0);
840
891
 
841
892
  // cleanup on disconnect
842
893
  conn.on("close", () => {
@@ -878,15 +929,9 @@ function startDaemon() {
878
929
 
879
930
  async function tgSend(chatId, text) {
880
931
  const token = process.env.TELEGRAM_BOT_TOKEN;
881
- // telegram max message length is 4096
882
- const chunks = [];
883
- for (let i = 0; i < text.length; i += 4000) chunks.push(text.slice(i, i + 4000));
884
- for (const chunk of chunks) {
885
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
886
- method: "POST",
887
- headers: { "content-type": "application/json" },
888
- body: JSON.stringify({ chat_id: chatId, text: chunk }),
889
- });
932
+ // telegram max message length is 4096; chunk under it
933
+ for (let i = 0; i < text.length; i += 4000) {
934
+ await telegramSend(token, chatId, text.slice(i, i + 4000));
890
935
  }
891
936
  }
892
937
 
@@ -1088,6 +1133,15 @@ export function buildSafeEnv(extra) {
1088
1133
  return safeEnv;
1089
1134
  }
1090
1135
 
1136
+ // start default jsonl logging for a session, under the session's own cwd (the
1137
+ // client's dir, not the daemon's frozen cwd). used by the `--log` shortcut on
1138
+ // spawn/wrap; the `log` command does its own format/dir handling. returns path.
1139
+ function startDefaultLog(mgr, name) {
1140
+ const logPath = join(mgr.get(name).cwd, "agents", "logs", `${name}-${Date.now()}.jsonl`);
1141
+ mgr.get(name).startLog(logPath, "jsonl");
1142
+ return logPath;
1143
+ }
1144
+
1091
1145
  // POSIX single-quote escaping: wrap in '...' and replace each ' with '\''.
1092
1146
  // Makes a token literal to the shell -- no expansion, no command substitution,
1093
1147
  // no word splitting. Used to build the `zsh -lic` command line for `wrap`.
@@ -1095,6 +1149,17 @@ export function shellQuote(arg) {
1095
1149
  return `'${String(arg).replace(/'/g, "'\\''")}'`;
1096
1150
  }
1097
1151
 
1152
+ // one place that talks to the Telegram Bot API. returns the fetch promise so
1153
+ // callers keep their existing error semantics (handleCommand's try/catch for
1154
+ // the tg-send/tg-wait commands; the poller's own catch for notifications).
1155
+ function telegramSend(token, chatId, text) {
1156
+ return fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1157
+ method: "POST",
1158
+ headers: { "content-type": "application/json" },
1159
+ body: JSON.stringify({ chat_id: chatId, text }),
1160
+ });
1161
+ }
1162
+
1098
1163
  async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1099
1164
  const { cmd, name, args } = req;
1100
1165
 
@@ -1153,8 +1218,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1153
1218
  const cmdToRun = args?.cmd || "zsh";
1154
1219
  const cmdArgs = args?.args || [];
1155
1220
  // clamp terminal size to prevent memory exhaustion
1156
- const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1157
- const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1221
+ const cols = clampCols(args?.cols, config.cols);
1222
+ const rows = clampRows(args?.rows, config.rows);
1158
1223
  const safeEnv = buildSafeEnv(args?.env);
1159
1224
  safeEnv.PTY_MGR_SESSION = name;
1160
1225
  const opts = {
@@ -1165,14 +1230,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1165
1230
  };
1166
1231
  mgr.spawn(name, cmdToRun, cmdArgs, opts);
1167
1232
  const res = { ok: true, name, pid: mgr.pid(name) };
1168
- // auto-start logging if --log flag. logs live under the session's own
1169
- // cwd (client's dir), not the daemon's frozen cwd.
1170
- if (args?.log) {
1171
- const logDir = join(mgr.get(name).cwd, "agents", "logs");
1172
- const logPath = join(logDir, `${name}-${Date.now()}.jsonl`);
1173
- mgr.get(name).startLog(logPath, "jsonl");
1174
- res.logPath = logPath;
1175
- }
1233
+ if (args?.log) res.logPath = startDefaultLog(mgr, name);
1176
1234
  return res;
1177
1235
  }
1178
1236
  case "wrap": {
@@ -1195,8 +1253,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1195
1253
  if (maxNum === 0) maxNum = 1;
1196
1254
  const nextName = `${baseName}-${maxNum}`;
1197
1255
 
1198
- const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1199
- const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1256
+ const cols = clampCols(args?.cols, config.cols);
1257
+ const rows = clampRows(args?.rows, config.rows);
1200
1258
 
1201
1259
  // wrap spawns through user's login shell so shell functions/aliases work
1202
1260
  // (like tmux does). this means glm, nvm, conda, etc. all resolve.
@@ -1223,13 +1281,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1223
1281
 
1224
1282
  mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
1225
1283
  const res = { ok: true, name: nextName, pid: mgr.pid(nextName) };
1226
-
1227
- if (args?.log) {
1228
- const logDir = join(clientCwd, "agents", "logs");
1229
- const logPath = join(logDir, `${nextName}-${Date.now()}.jsonl`);
1230
- mgr.get(nextName).startLog(logPath, "jsonl");
1231
- res.logPath = logPath;
1232
- }
1284
+ if (args?.log) res.logPath = startDefaultLog(mgr, nextName);
1233
1285
  return res;
1234
1286
  }
1235
1287
  case "send": {
@@ -1346,11 +1398,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1346
1398
  const msg = req.args?.message;
1347
1399
  if (!msg) return { ok: false, error: "message required" };
1348
1400
  if (req.args?.session) tgState.lastSession = req.args.session;
1349
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1350
- method: "POST",
1351
- headers: { "content-type": "application/json" },
1352
- body: JSON.stringify({ chat_id: chatId, text: msg }),
1353
- });
1401
+ await telegramSend(token, chatId, msg);
1354
1402
  return { ok: true };
1355
1403
  }
1356
1404
  case "tg-wait": {
@@ -1360,11 +1408,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1360
1408
  if (tgState.waiter) return { ok: false, error: "ALREADY_WAITING" };
1361
1409
  const msg = req.args?.message;
1362
1410
  const timeoutMs = req.args?.timeout ?? 60000;
1363
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1364
- method: "POST",
1365
- headers: { "content-type": "application/json" },
1366
- body: JSON.stringify({ chat_id: chatId, text: msg }),
1367
- });
1411
+ await telegramSend(token, chatId, msg);
1368
1412
  const sessionName = req.args?.session;
1369
1413
  return new Promise((resolve) => {
1370
1414
  const timer = setTimeout(() => {
@@ -1389,58 +1433,55 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1389
1433
  }
1390
1434
 
1391
1435
  /**
1392
- * client: send a single command to the daemon, print result.
1436
+ * client: send one newline-framed JSON command to a daemon socket and resolve
1437
+ * with the parsed JSON reply. Rejects with a friendly message when the daemon
1438
+ * is absent, and — unlike a bare JSON.parse in the data handler — turns a
1439
+ * malformed or truncated reply into a rejection instead of an uncaught throw.
1393
1440
  */
1394
- function sendCommandTo(sock, req) {
1441
+ function requestSocket(sock, req, notRunningMsg) {
1395
1442
  return new Promise((resolve, reject) => {
1396
1443
  const conn = createConnection(sock);
1444
+ let buf = "";
1445
+ let settled = false;
1446
+ const settle = (fn, value) => {
1447
+ if (settled) return;
1448
+ settled = true;
1449
+ try { conn.end(); } catch {}
1450
+ fn(value);
1451
+ };
1452
+ const parseAndSettle = (chunk) => {
1453
+ try { settle(resolve, JSON.parse(chunk)); }
1454
+ catch (err) { settle(reject, new Error(`bad response from daemon: ${err.message}`)); }
1455
+ };
1397
1456
  conn.on("error", (err) => {
1398
- if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1399
- reject(new Error("daemon not running"));
1400
- } else {
1401
- reject(err);
1402
- }
1457
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") settle(reject, new Error(notRunningMsg));
1458
+ else settle(reject, err);
1403
1459
  });
1404
1460
  conn.on("connect", () => {
1405
- conn.write(JSON.stringify(req) + "\n");
1461
+ try { conn.write(JSON.stringify(req) + "\n"); }
1462
+ catch (err) { settle(reject, err); }
1406
1463
  });
1407
- let buf = "";
1408
1464
  conn.on("data", (data) => {
1409
1465
  buf += data.toString();
1410
1466
  const nl = buf.indexOf("\n");
1411
- if (nl !== -1) {
1412
- const res = JSON.parse(buf.slice(0, nl));
1413
- conn.end();
1414
- resolve(res);
1415
- }
1467
+ if (nl !== -1) parseAndSettle(buf.slice(0, nl));
1468
+ });
1469
+ conn.on("end", () => {
1470
+ if (settled) return;
1471
+ if (buf) parseAndSettle(buf); // reply without a trailing newline
1472
+ else settle(reject, new Error(notRunningMsg));
1416
1473
  });
1417
1474
  });
1418
1475
  }
1419
1476
 
1477
+ // send to a specific socket file (used by `stop all` sweeping ~/.pty-manager).
1478
+ function sendCommandTo(sock, req) {
1479
+ return requestSocket(sock, req, "daemon not running");
1480
+ }
1481
+
1482
+ // send to the daemon selected by @name / --daemon / $PTY_DAEMON.
1420
1483
  function sendCommand(req) {
1421
- return new Promise((resolve, reject) => {
1422
- const conn = createConnection(SOCKET_PATH);
1423
- conn.on("error", (err) => {
1424
- if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1425
- reject(new Error("daemon not running. start with: pty-mgr daemon"));
1426
- } else {
1427
- reject(err);
1428
- }
1429
- });
1430
- conn.on("connect", () => {
1431
- conn.write(JSON.stringify(req) + "\n");
1432
- });
1433
- let buf = "";
1434
- conn.on("data", (data) => {
1435
- buf += data.toString();
1436
- const nl = buf.indexOf("\n");
1437
- if (nl !== -1) {
1438
- const res = JSON.parse(buf.slice(0, nl));
1439
- conn.end();
1440
- resolve(res);
1441
- }
1442
- });
1443
- });
1484
+ return requestSocket(SOCKET_PATH, req, "daemon not running. start with: pty-mgr daemon");
1444
1485
  }
1445
1486
 
1446
1487
  /**
@@ -1465,11 +1506,19 @@ function attachToSession(name) {
1465
1506
  });
1466
1507
 
1467
1508
  conn.on("connect", () => {
1468
- // send attach request
1469
- 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");
1470
1518
 
1471
1519
  let gotAck = false;
1472
1520
  let headerBuf = "";
1521
+ let onStdoutResize = null;
1473
1522
 
1474
1523
  // last-seen alt-screen state: detach pops the client back to its
1475
1524
  // normal screen only if the session left it in the alternate buffer
@@ -1509,11 +1558,11 @@ function attachToSession(name) {
1509
1558
  gotAck = true;
1510
1559
  altActive = !!ack.alt;
1511
1560
 
1512
- // resize client terminal to match session
1513
- if (ack.cols && ack.rows) {
1514
- // CSI 8 ; rows ; cols t = resize terminal window
1515
- process.stdout.write(`\x1b[8;${ack.rows};${ack.cols}t`);
1516
- }
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.
1517
1566
 
1518
1567
  // put terminal in raw mode
1519
1568
  process.stdin.setRawMode(true);
@@ -1537,6 +1586,15 @@ function attachToSession(name) {
1537
1586
  conn.write(key);
1538
1587
  });
1539
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
+
1540
1598
  return;
1541
1599
  }
1542
1600
 
@@ -1553,6 +1611,10 @@ function attachToSession(name) {
1553
1611
  function detach() {
1554
1612
  if (detached || failed) return;
1555
1613
  detached = true;
1614
+ if (onStdoutResize) {
1615
+ process.stdout.removeListener("resize", onStdoutResize);
1616
+ onStdoutResize = null;
1617
+ }
1556
1618
  if (process.stdin.isRaw) {
1557
1619
  process.stdin.setRawMode(false);
1558
1620
  process.stdin.pause();
@@ -1994,6 +2056,20 @@ function capturePayloadText(res) {
1994
2056
  return res.output || "";
1995
2057
  }
1996
2058
 
2059
+ // capture a session (or glob/all) twice, intervalMs apart, and report whether
2060
+ // the rendered bottom-100 lines are stable ("done") or still changing
2061
+ // ("working"). shared by `p watch` and the flow engine's turn-completion check.
2062
+ async function captureStability(name, intervalMs) {
2063
+ const capture = () => sendCommand({ cmd: "capture", name, args: { lines: 100 } });
2064
+ const first = await capture();
2065
+ if (!first.ok) throw new Error(first.error || `failed to capture ${name}`);
2066
+ const firstHash = hashText(capturePayloadText(first));
2067
+ await sleep(intervalMs);
2068
+ const second = await capture();
2069
+ if (!second.ok) throw new Error(second.error || `failed to capture ${name}`);
2070
+ return firstHash === hashText(capturePayloadText(second)) ? "done" : "working";
2071
+ }
2072
+
1997
2073
  function expandHome(value) {
1998
2074
  if (!value) return value;
1999
2075
  if (value === "~") return homedir();
@@ -2191,11 +2267,14 @@ export function transcriptRootsForKind(kind, config, cwd = process.cwd()) {
2191
2267
  .filter((root, index, roots) => existsSync(root) || index === roots.length - 1);
2192
2268
  }
2193
2269
 
2270
+ // flatten every .jsonl transcript under a kind's configured roots.
2271
+ function transcriptFiles(kind, config, cwd = process.cwd()) {
2272
+ return transcriptRootsForKind(kind, config, cwd).flatMap((root) => walkJsonlFiles(root));
2273
+ }
2274
+
2194
2275
  export function findNewestTranscript({ kind, cwd = process.cwd(), sinceMs = 0, config }) {
2195
2276
  const adapter = adapterForKind(kind, config);
2196
- const roots = transcriptRootsForKind(kind, config, cwd);
2197
- const candidates = roots
2198
- .flatMap((root) => walkJsonlFiles(root))
2277
+ const candidates = transcriptFiles(kind, config, cwd)
2199
2278
  .map((path) => {
2200
2279
  const mtimeMs = statSync(path).mtimeMs;
2201
2280
  const startedMs = transcriptStartedAtMs(path, adapter);
@@ -2338,8 +2417,7 @@ export function findTranscriptForSentMessage({
2338
2417
  }) {
2339
2418
  if (file) return findSentMessageInTranscript(file, kind, text, config);
2340
2419
 
2341
- const candidates = transcriptRootsForKind(kind, config, cwd)
2342
- .flatMap((root) => walkJsonlFiles(root))
2420
+ const candidates = transcriptFiles(kind, config, cwd)
2343
2421
  .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
2344
2422
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
2345
2423
 
@@ -2489,14 +2567,7 @@ async function sendFlowMessageConfirmed(meta, text, config, deps = {}) {
2489
2567
 
2490
2568
  async function watchFlowSession(session, intervalMs, deps = {}) {
2491
2569
  if (deps.watchSession) return deps.watchSession(session, intervalMs);
2492
- const first = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2493
- if (!first.ok) throw new Error(first.error || `failed to capture ${session}`);
2494
- const firstHash = hashText(capturePayloadText(first));
2495
- await sleep(intervalMs);
2496
- const second = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2497
- if (!second.ok) throw new Error(second.error || `failed to capture ${session}`);
2498
- const secondHash = hashText(capturePayloadText(second));
2499
- return firstHash === secondHash ? "done" : "working";
2570
+ return captureStability(session, intervalMs);
2500
2571
  }
2501
2572
 
2502
2573
  async function waitForFlowMessage({
@@ -2674,8 +2745,6 @@ function detectRcFile() {
2674
2745
  }
2675
2746
 
2676
2747
  async function runSetup() {
2677
- const { appendFileSync, readFileSync, writeFileSync } = await import("node:fs");
2678
-
2679
2748
  console.log("pty-mgr setup");
2680
2749
  console.log("wrap CLI tools in managed PTY sessions.\n");
2681
2750
  console.log("when you type a wrapped command (e.g. claude), pty-mgr will:");
@@ -2952,12 +3021,11 @@ async function cli() {
2952
3021
  if (command === "daemon") {
2953
3022
  // fork into background as a true daemon (survives terminal close)
2954
3023
  if (!process.env.__PTY_DAEMON_CHILD) {
2955
- const { spawn: cpSpawn } = await import("node:child_process");
2956
3024
  // re-exec ourselves with the same args
2957
3025
  // process.execPath = bun (dev) or the compiled binary
2958
3026
  // filter out internal /$bunfs/ paths from argv
2959
3027
  const realArgs = process.argv.slice(1).filter(a => !a.startsWith("/$bunfs/"));
2960
- const child = cpSpawn(process.execPath, realArgs, {
3028
+ const child = spawnChild(process.execPath, realArgs, {
2961
3029
  detached: true,
2962
3030
  stdio: ["ignore", "ignore", "ignore", "ipc"],
2963
3031
  env: { ...process.env, __PTY_DAEMON_CHILD: "1" },
@@ -2991,7 +3059,6 @@ async function cli() {
2991
3059
  const target = args[0]; // "all" or undefined (= current daemon)
2992
3060
  if (target === "all") {
2993
3061
  // find all pty-manager sockets and shut them down
2994
- const { readdirSync } = await import("node:fs");
2995
3062
  const dir = join(homedir(), ".pty-manager");
2996
3063
  let socks = [];
2997
3064
  try {
@@ -3079,20 +3146,7 @@ async function cli() {
3079
3146
  }
3080
3147
 
3081
3148
  try {
3082
- const first = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
3083
- if (!first.ok) {
3084
- console.error("error:", first.error);
3085
- process.exit(1);
3086
- }
3087
- const firstHash = hashText(capturePayloadText(first));
3088
- await sleep(intervalMs);
3089
- const second = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
3090
- if (!second.ok) {
3091
- console.error("error:", second.error);
3092
- process.exit(1);
3093
- }
3094
- const secondHash = hashText(capturePayloadText(second));
3095
- console.log(firstHash === secondHash ? "done" : "working");
3149
+ console.log(await captureStability(name, intervalMs));
3096
3150
  process.exit(0);
3097
3151
  } catch (err) {
3098
3152
  console.error(err.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentiko/pty-mgr",
3
- "version": "1.4.1",
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.1",
30
- "@mentiko/pty-mgr-linux-arm64": "1.4.1",
31
- "@mentiko/pty-mgr-darwin-x64": "1.4.1",
32
- "@mentiko/pty-mgr-darwin-arm64": "1.4.1"
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"