@mentiko/pty-mgr 1.3.1 → 1.4.2

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.
Files changed (2) hide show
  1. package/lib/pty-manager.mjs +350 -203
  2. package/package.json +5 -5
@@ -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";
@@ -38,10 +35,14 @@ import {
38
35
  readFileSync,
39
36
  readdirSync,
40
37
  statSync,
38
+ writeFileSync,
39
+ appendFileSync,
41
40
  } from "node:fs";
41
+ import { fileURLToPath } from "node:url";
42
+ import { spawn as spawnChild } from "node:child_process";
42
43
 
43
44
  // kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
44
- export const VERSION = "1.3.1";
45
+ export const VERSION = "1.4.2";
45
46
  import xterm from "@xterm/headless";
46
47
  import { SerializeAddon } from "@xterm/addon-serialize";
47
48
 
@@ -65,6 +66,15 @@ export function validateSessionName(name) {
65
66
  }
66
67
  }
67
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
+
68
78
  class PtySession {
69
79
  constructor(name, opts = {}) {
70
80
  this.name = name;
@@ -149,16 +159,9 @@ class PtySession {
149
159
  }
150
160
 
151
161
  /**
152
- * capture the rendered screen buffer.
153
- *
154
- * returns what you'd actually see on the terminal right now.
155
- * escape codes, cursor movements, line erases -- all resolved.
156
- * equivalent of reading the full terminal screen buffer.
157
- *
158
- * @param {number} [tailLines] - last N lines (0 or omit = visible screen)
159
- * @param {object} [opts] - options
160
- * @param {boolean} [opts.screen] - only capture visible rows (skip scrollback)
161
- * @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).
162
165
  */
163
166
  capture(tailLines, opts = {}) {
164
167
  const buf = this.terminal.buffer.active;
@@ -184,21 +187,15 @@ class PtySession {
184
187
  return lines.join("\n");
185
188
  }
186
189
 
187
- /**
188
- * capture the rendered screen with ANSI color/attribute escape codes.
189
- * uses @xterm/addon-serialize to preserve colors, bold, etc.
190
- * @returns {string}
191
- */
190
+ /** rendered screen with ANSI color/attribute codes (via addon-serialize). */
192
191
  captureAnsi() {
193
192
  return this._serializer.serialize();
194
193
  }
195
194
 
196
195
  /**
197
- * capture visible content with ANSI colors, trimmed of empty lines.
198
- * reads cells individually to build colored output line by line.
199
- * result starts from line 0 (no leading empty rows).
200
- * @param {number} [tailLines] - last N lines (0 = all content)
201
- * @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.
202
199
  */
203
200
  captureAnsiCompact(tailLines) {
204
201
  const buf = this.terminal.buffer.active;
@@ -210,14 +207,17 @@ class PtySession {
210
207
  if (!line) { lines.push(""); continue; }
211
208
 
212
209
  let out = "";
210
+ let keep = 0; // output length up to the last cell worth keeping
213
211
  let prevFg = -1, prevBg = -1, prevBold = false, prevDim = false;
214
212
  let prevItalic = false, prevUnder = false, prevInverse = false;
215
213
 
216
214
  for (let x = 0; x < line.length; x++) {
217
215
  const cell = line.getCell(x);
218
216
  if (!cell) continue;
219
- const ch = cell.getChars();
220
- if (!ch) continue;
217
+ if (cell.getWidth() === 0) continue; // wide-char continuation cell
218
+ // null cells (never written; TUIs skip them with cursor moves)
219
+ // must still occupy a column, or text collapses together
220
+ const ch = cell.getChars() || " ";
221
221
 
222
222
  const fg = cell.getFgColor();
223
223
  const bg = cell.getBgColor();
@@ -258,8 +258,13 @@ class PtySession {
258
258
  prevItalic = italic; prevUnder = under; prevInverse = inverse;
259
259
  }
260
260
  out += ch;
261
+ if (ch !== " " || fgMode !== 0 || bgMode !== 0 ||
262
+ bold || dim || italic || under || inverse) {
263
+ keep = out.length;
264
+ }
261
265
  }
262
- if (prevFg !== -1 || prevBold) out += "\x1b[0m";
266
+ out = out.slice(0, keep); // drop trailing unstyled blanks
267
+ if (keep > 0 && (prevFg !== -1 || prevBold)) out += "\x1b[0m";
263
268
  lines.push(out);
264
269
  }
265
270
 
@@ -278,14 +283,10 @@ class PtySession {
278
283
  return lines.join("\r\n");
279
284
  }
280
285
 
281
- /**
282
- * resize the PTY and headless terminal.
283
- * @param {number} cols
284
- * @param {number} rows
285
- */
286
+ /** resize the PTY and headless terminal (clamped to sane bounds). */
286
287
  resize(cols, rows) {
287
- cols = Math.max(20, Math.min(500, cols));
288
- rows = Math.max(5, Math.min(200, rows));
288
+ cols = clampCols(cols, cols);
289
+ rows = clampRows(rows, rows);
289
290
  this.terminal.resize(cols, rows);
290
291
  if (this._pty && !this.exited) {
291
292
  try { this._pty.resize(cols, rows); } catch {}
@@ -324,15 +325,10 @@ class PtySession {
324
325
  }
325
326
 
326
327
  /**
327
- * start logging session output to a file.
328
- *
329
- * format:
328
+ * start logging session output to a file. format:
330
329
  * "raw" - raw PTY bytes (escape codes included, replayable)
331
- * "rendered" - clean screen snapshots on each data event
332
- * "jsonl" - timestamped JSON lines { t, type, data }
333
- *
334
- * @param {string} logPath - file path to write to
335
- * @param {string} [format="raw"] - log format
330
+ * "rendered" - clean screen snapshots on each data event
331
+ * "jsonl" - timestamped JSON lines { t, type, data }
336
332
  */
337
333
  startLog(logPath, format = "raw") {
338
334
  if (this._logStream) this.stopLog();
@@ -343,6 +339,10 @@ class PtySession {
343
339
  this._logPath = logPath;
344
340
  this._logFormat = format;
345
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 {} });
346
346
 
347
347
  // write header for jsonl
348
348
  if (format === "jsonl") {
@@ -459,18 +459,9 @@ export class PtyManager {
459
459
  }
460
460
 
461
461
  /**
462
- * spawn a command inside a real PTY with terminal emulation
463
- *
464
- * @param {string} name - unique session name
465
- * @param {string} cmd - command to run
466
- * @param {string[]} [args] - arguments
467
- * @param {object} [opts]
468
- * @param {string} [opts.cwd] - working directory
469
- * @param {object} [opts.env] - extra env vars
470
- * @param {number} [opts.cols] - terminal columns (default 200)
471
- * @param {number} [opts.rows] - terminal rows (default 50)
472
- * @param {number} [opts.scrollback] - scrollback lines (default 5000)
473
- * @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.
474
465
  */
475
466
  spawn(name, cmd, args = [], opts = {}) {
476
467
  validateSessionName(name);
@@ -765,9 +756,11 @@ function startDaemon() {
765
756
  });
766
757
 
767
758
  conn.on("data", async (data) => {
768
- // in attach mode, forward raw input to the pty
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.
769
762
  if (attached) {
770
- attached.write(data.toString());
763
+ try { attached.write(data.toString()); } catch {}
771
764
  return;
772
765
  }
773
766
 
@@ -790,15 +783,18 @@ function startDaemon() {
790
783
  const session = mgr.get(req.name);
791
784
  const cols = session.terminal.cols;
792
785
  const rows = session.terminal.rows;
793
- // send initial ack with terminal size
794
- conn.write(JSON.stringify({ ok: true, mode: "attach", cols, rows }) + "\n");
795
-
796
- // send current buffer as plain text, then SIGWINCH for colored redraw
797
- conn.write("\x1b[2J\x1b[H");
798
- const screen = session.capture(session.terminal.rows);
799
- if (screen) conn.write(screen.replace(/\n/g, "\r\n") + "\r\n");
800
-
801
- // force TUI apps to redraw on top
786
+ const alt = session.terminal.buffer.active.type === "alternate";
787
+ // send initial ack with terminal size + buffer state
788
+ conn.write(JSON.stringify({ ok: true, mode: "attach", cols, rows, alt }) + "\n");
789
+
790
+ // replay the session's full terminal state: scrollback, screen,
791
+ // colors, cursor, modes. history lands in the client's own
792
+ // scrollback so it stays scrollable. if the session is inside a
793
+ // TUI, the serializer emits the alt-screen switch itself and the
794
+ // client pops back out on detach.
795
+ conn.write("\x1b[2J\x1b[H" + session.captureAnsi());
796
+
797
+ // nudge the app to repaint so live output aligns with the replay
802
798
  if (session.childPid) {
803
799
  try { process.kill(session.childPid, "SIGWINCH"); } catch {}
804
800
  }
@@ -812,7 +808,10 @@ function startDaemon() {
812
808
  // when session exits, notify and close
813
809
  const onExit = () => {
814
810
  try {
815
- conn.write("\r\n[session exited]\r\n");
811
+ if (session.terminal.buffer.active.type === "alternate") {
812
+ conn.write("\x1b[?1049l");
813
+ }
814
+ conn.write("\x1b[0m\r\n[session exited]\r\n");
816
815
  conn.end();
817
816
  } catch {}
818
817
  };
@@ -861,15 +860,9 @@ function startDaemon() {
861
860
 
862
861
  async function tgSend(chatId, text) {
863
862
  const token = process.env.TELEGRAM_BOT_TOKEN;
864
- // telegram max message length is 4096
865
- const chunks = [];
866
- for (let i = 0; i < text.length; i += 4000) chunks.push(text.slice(i, i + 4000));
867
- for (const chunk of chunks) {
868
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
869
- method: "POST",
870
- headers: { "content-type": "application/json" },
871
- body: JSON.stringify({ chat_id: chatId, text: chunk }),
872
- });
863
+ // telegram max message length is 4096; chunk under it
864
+ for (let i = 0; i < text.length; i += 4000) {
865
+ await telegramSend(token, chatId, text.slice(i, i + 4000));
873
866
  }
874
867
  }
875
868
 
@@ -1071,6 +1064,15 @@ export function buildSafeEnv(extra) {
1071
1064
  return safeEnv;
1072
1065
  }
1073
1066
 
1067
+ // start default jsonl logging for a session, under the session's own cwd (the
1068
+ // client's dir, not the daemon's frozen cwd). used by the `--log` shortcut on
1069
+ // spawn/wrap; the `log` command does its own format/dir handling. returns path.
1070
+ function startDefaultLog(mgr, name) {
1071
+ const logPath = join(mgr.get(name).cwd, "agents", "logs", `${name}-${Date.now()}.jsonl`);
1072
+ mgr.get(name).startLog(logPath, "jsonl");
1073
+ return logPath;
1074
+ }
1075
+
1074
1076
  // POSIX single-quote escaping: wrap in '...' and replace each ' with '\''.
1075
1077
  // Makes a token literal to the shell -- no expansion, no command substitution,
1076
1078
  // no word splitting. Used to build the `zsh -lic` command line for `wrap`.
@@ -1078,6 +1080,17 @@ export function shellQuote(arg) {
1078
1080
  return `'${String(arg).replace(/'/g, "'\\''")}'`;
1079
1081
  }
1080
1082
 
1083
+ // one place that talks to the Telegram Bot API. returns the fetch promise so
1084
+ // callers keep their existing error semantics (handleCommand's try/catch for
1085
+ // the tg-send/tg-wait commands; the poller's own catch for notifications).
1086
+ function telegramSend(token, chatId, text) {
1087
+ return fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1088
+ method: "POST",
1089
+ headers: { "content-type": "application/json" },
1090
+ body: JSON.stringify({ chat_id: chatId, text }),
1091
+ });
1092
+ }
1093
+
1081
1094
  async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1082
1095
  const { cmd, name, args } = req;
1083
1096
 
@@ -1093,6 +1106,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1093
1106
  name: DAEMON_NAME,
1094
1107
  pid: process.pid,
1095
1108
  socket: SOCKET_PATH,
1109
+ cwd: process.cwd(),
1096
1110
  startedAt: daemonStartedAt.toISOString(),
1097
1111
  uptimeMs,
1098
1112
  uptime: formatUptime(uptimeMs),
@@ -1135,8 +1149,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1135
1149
  const cmdToRun = args?.cmd || "zsh";
1136
1150
  const cmdArgs = args?.args || [];
1137
1151
  // clamp terminal size to prevent memory exhaustion
1138
- const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1139
- const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1152
+ const cols = clampCols(args?.cols, config.cols);
1153
+ const rows = clampRows(args?.rows, config.rows);
1140
1154
  const safeEnv = buildSafeEnv(args?.env);
1141
1155
  safeEnv.PTY_MGR_SESSION = name;
1142
1156
  const opts = {
@@ -1147,13 +1161,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1147
1161
  };
1148
1162
  mgr.spawn(name, cmdToRun, cmdArgs, opts);
1149
1163
  const res = { ok: true, name, pid: mgr.pid(name) };
1150
- // auto-start logging if --log flag
1151
- if (args?.log) {
1152
- const logDir = join(process.cwd(), "agents", "logs");
1153
- const logPath = join(logDir, `${name}-${Date.now()}.jsonl`);
1154
- mgr.get(name).startLog(logPath, "jsonl");
1155
- res.logPath = logPath;
1156
- }
1164
+ if (args?.log) res.logPath = startDefaultLog(mgr, name);
1157
1165
  return res;
1158
1166
  }
1159
1167
  case "wrap": {
@@ -1176,8 +1184,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1176
1184
  if (maxNum === 0) maxNum = 1;
1177
1185
  const nextName = `${baseName}-${maxNum}`;
1178
1186
 
1179
- const cols = Math.max(20, Math.min(500, args?.cols || config.cols));
1180
- const rows = Math.max(5, Math.min(200, args?.rows || config.rows));
1187
+ const cols = clampCols(args?.cols, config.cols);
1188
+ const rows = clampRows(args?.rows, config.rows);
1181
1189
 
1182
1190
  // wrap spawns through user's login shell so shell functions/aliases work
1183
1191
  // (like tmux does). this means glm, nvm, conda, etc. all resolve.
@@ -1204,13 +1212,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1204
1212
 
1205
1213
  mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
1206
1214
  const res = { ok: true, name: nextName, pid: mgr.pid(nextName) };
1207
-
1208
- if (args?.log) {
1209
- const logDir = join(clientCwd, "agents", "logs");
1210
- const logPath = join(logDir, `${nextName}-${Date.now()}.jsonl`);
1211
- mgr.get(nextName).startLog(logPath, "jsonl");
1212
- res.logPath = logPath;
1213
- }
1215
+ if (args?.log) res.logPath = startDefaultLog(mgr, nextName);
1214
1216
  return res;
1215
1217
  }
1216
1218
  case "send": {
@@ -1301,10 +1303,11 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1301
1303
  const path = session.stopLog();
1302
1304
  return { ok: true, stopped: true, path };
1303
1305
  }
1304
- // default log dir: ./agents/logs/
1306
+ // default log dir: <session cwd>/agents/logs/ -- follow the session's
1307
+ // own dir, not the daemon's frozen cwd.
1305
1308
  const format = args?.format || "jsonl";
1306
1309
  const ext = format === "jsonl" ? "jsonl" : format === "rendered" ? "log" : "raw";
1307
- const logDir = args?.dir || join(process.cwd(), "agents", "logs");
1310
+ const logDir = args?.dir || join(session.cwd, "agents", "logs");
1308
1311
  const ts = Date.now();
1309
1312
  const logPath = args?.path || join(logDir, `${name}-${ts}.${ext}`);
1310
1313
  session.startLog(logPath, format);
@@ -1326,11 +1329,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1326
1329
  const msg = req.args?.message;
1327
1330
  if (!msg) return { ok: false, error: "message required" };
1328
1331
  if (req.args?.session) tgState.lastSession = req.args.session;
1329
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1330
- method: "POST",
1331
- headers: { "content-type": "application/json" },
1332
- body: JSON.stringify({ chat_id: chatId, text: msg }),
1333
- });
1332
+ await telegramSend(token, chatId, msg);
1334
1333
  return { ok: true };
1335
1334
  }
1336
1335
  case "tg-wait": {
@@ -1340,11 +1339,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1340
1339
  if (tgState.waiter) return { ok: false, error: "ALREADY_WAITING" };
1341
1340
  const msg = req.args?.message;
1342
1341
  const timeoutMs = req.args?.timeout ?? 60000;
1343
- await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1344
- method: "POST",
1345
- headers: { "content-type": "application/json" },
1346
- body: JSON.stringify({ chat_id: chatId, text: msg }),
1347
- });
1342
+ await telegramSend(token, chatId, msg);
1348
1343
  const sessionName = req.args?.session;
1349
1344
  return new Promise((resolve) => {
1350
1345
  const timer = setTimeout(() => {
@@ -1369,58 +1364,55 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1369
1364
  }
1370
1365
 
1371
1366
  /**
1372
- * client: send a single command to the daemon, print result.
1367
+ * client: send one newline-framed JSON command to a daemon socket and resolve
1368
+ * with the parsed JSON reply. Rejects with a friendly message when the daemon
1369
+ * is absent, and — unlike a bare JSON.parse in the data handler — turns a
1370
+ * malformed or truncated reply into a rejection instead of an uncaught throw.
1373
1371
  */
1374
- function sendCommandTo(sock, req) {
1372
+ function requestSocket(sock, req, notRunningMsg) {
1375
1373
  return new Promise((resolve, reject) => {
1376
1374
  const conn = createConnection(sock);
1375
+ let buf = "";
1376
+ let settled = false;
1377
+ const settle = (fn, value) => {
1378
+ if (settled) return;
1379
+ settled = true;
1380
+ try { conn.end(); } catch {}
1381
+ fn(value);
1382
+ };
1383
+ const parseAndSettle = (chunk) => {
1384
+ try { settle(resolve, JSON.parse(chunk)); }
1385
+ catch (err) { settle(reject, new Error(`bad response from daemon: ${err.message}`)); }
1386
+ };
1377
1387
  conn.on("error", (err) => {
1378
- if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1379
- reject(new Error("daemon not running"));
1380
- } else {
1381
- reject(err);
1382
- }
1388
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") settle(reject, new Error(notRunningMsg));
1389
+ else settle(reject, err);
1383
1390
  });
1384
1391
  conn.on("connect", () => {
1385
- conn.write(JSON.stringify(req) + "\n");
1392
+ try { conn.write(JSON.stringify(req) + "\n"); }
1393
+ catch (err) { settle(reject, err); }
1386
1394
  });
1387
- let buf = "";
1388
1395
  conn.on("data", (data) => {
1389
1396
  buf += data.toString();
1390
1397
  const nl = buf.indexOf("\n");
1391
- if (nl !== -1) {
1392
- const res = JSON.parse(buf.slice(0, nl));
1393
- conn.end();
1394
- resolve(res);
1395
- }
1398
+ if (nl !== -1) parseAndSettle(buf.slice(0, nl));
1399
+ });
1400
+ conn.on("end", () => {
1401
+ if (settled) return;
1402
+ if (buf) parseAndSettle(buf); // reply without a trailing newline
1403
+ else settle(reject, new Error(notRunningMsg));
1396
1404
  });
1397
1405
  });
1398
1406
  }
1399
1407
 
1408
+ // send to a specific socket file (used by `stop all` sweeping ~/.pty-manager).
1409
+ function sendCommandTo(sock, req) {
1410
+ return requestSocket(sock, req, "daemon not running");
1411
+ }
1412
+
1413
+ // send to the daemon selected by @name / --daemon / $PTY_DAEMON.
1400
1414
  function sendCommand(req) {
1401
- return new Promise((resolve, reject) => {
1402
- const conn = createConnection(SOCKET_PATH);
1403
- conn.on("error", (err) => {
1404
- if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1405
- reject(new Error("daemon not running. start with: pty-mgr daemon"));
1406
- } else {
1407
- reject(err);
1408
- }
1409
- });
1410
- conn.on("connect", () => {
1411
- conn.write(JSON.stringify(req) + "\n");
1412
- });
1413
- let buf = "";
1414
- conn.on("data", (data) => {
1415
- buf += data.toString();
1416
- const nl = buf.indexOf("\n");
1417
- if (nl !== -1) {
1418
- const res = JSON.parse(buf.slice(0, nl));
1419
- conn.end();
1420
- resolve(res);
1421
- }
1422
- });
1423
- });
1415
+ return requestSocket(SOCKET_PATH, req, "daemon not running. start with: pty-mgr daemon");
1424
1416
  }
1425
1417
 
1426
1418
  /**
@@ -1431,8 +1423,12 @@ function sendCommand(req) {
1431
1423
  function attachToSession(name) {
1432
1424
  return new Promise((resolve, reject) => {
1433
1425
  const conn = createConnection(SOCKET_PATH);
1426
+ // set on any pre-attach failure so the close handler doesn't run the
1427
+ // detach cleanup (stray "detached" + terminal resets after an error)
1428
+ let failed = false;
1434
1429
 
1435
1430
  conn.on("error", (err) => {
1431
+ failed = true;
1436
1432
  if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1437
1433
  reject(new Error("daemon not running. start with: pty-mgr daemon"));
1438
1434
  } else {
@@ -1447,6 +1443,15 @@ function attachToSession(name) {
1447
1443
  let gotAck = false;
1448
1444
  let headerBuf = "";
1449
1445
 
1446
+ // last-seen alt-screen state: detach pops the client back to its
1447
+ // normal screen only if the session left it in the alternate buffer
1448
+ let altActive = false;
1449
+ const trackAlt = (chunk) => {
1450
+ const h = chunk.lastIndexOf("\x1b[?1049h");
1451
+ const l = chunk.lastIndexOf("\x1b[?1049l");
1452
+ if (h !== -1 || l !== -1) altActive = h > l;
1453
+ };
1454
+
1450
1455
  conn.on("data", (data) => {
1451
1456
  if (!gotAck) {
1452
1457
  // first line is the JSON ack
@@ -1461,19 +1466,20 @@ function attachToSession(name) {
1461
1466
  try {
1462
1467
  ack = JSON.parse(ackStr);
1463
1468
  if (!ack.ok) {
1464
- console.error("error:", ack.error);
1469
+ failed = true;
1465
1470
  conn.end();
1466
1471
  reject(new Error(ack.error));
1467
1472
  return;
1468
1473
  }
1469
1474
  } catch {
1470
- console.error("bad ack from daemon");
1475
+ failed = true;
1471
1476
  conn.end();
1472
- reject(new Error("bad ack"));
1477
+ reject(new Error("bad ack from daemon"));
1473
1478
  return;
1474
1479
  }
1475
1480
 
1476
1481
  gotAck = true;
1482
+ altActive = !!ack.alt;
1477
1483
 
1478
1484
  // resize client terminal to match session
1479
1485
  if (ack.cols && ack.rows) {
@@ -1489,6 +1495,7 @@ function attachToSession(name) {
1489
1495
 
1490
1496
  // write any remaining data after the ack
1491
1497
  if (remainder) {
1498
+ trackAlt(remainder);
1492
1499
  process.stdout.write(remainder);
1493
1500
  }
1494
1501
 
@@ -1506,6 +1513,7 @@ function attachToSession(name) {
1506
1513
  }
1507
1514
 
1508
1515
  // streaming mode: write pty output to terminal
1516
+ trackAlt(data);
1509
1517
  process.stdout.write(data);
1510
1518
  });
1511
1519
 
@@ -1515,15 +1523,22 @@ function attachToSession(name) {
1515
1523
 
1516
1524
  let detached = false;
1517
1525
  function detach() {
1518
- if (detached) return;
1526
+ if (detached || failed) return;
1519
1527
  detached = true;
1520
1528
  if (process.stdin.isRaw) {
1521
1529
  process.stdin.setRawMode(false);
1522
1530
  process.stdin.pause();
1523
1531
  process.stdin.removeAllListeners("data");
1524
1532
  }
1533
+ // pop the alt screen only if the session left us in one, then
1534
+ // reset SGR/cursor and any modes the replayed TUI enabled
1535
+ // (keypad, cursor keys, bracketed paste, mouse tracking)
1536
+ if (altActive) process.stdout.write("\x1b[?1049l");
1537
+ process.stdout.write(
1538
+ "\x1b[0m\x1b[?25h\x1b>\x1b[?1l\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?9l"
1539
+ );
1525
1540
  conn.end();
1526
- console.log("\r\ndetached");
1541
+ console.log("detached");
1527
1542
  resolve();
1528
1543
  }
1529
1544
  });
@@ -1569,6 +1584,8 @@ usage:
1569
1584
  p flow list [--verbose] [--config file] list configured agent workflows
1570
1585
  p flow show <name> [--config file] show one configured agent workflow
1571
1586
  p flow run <name> --task <text> run a configured agent workflow
1587
+ p flow new [name] [--global] scaffold an example flow (project cwd, or --global user config)
1588
+ p open config [editor] open config dir in editor (--local project, --defaults packaged)
1572
1589
  p demo run self-test (no daemon needed)
1573
1590
  p tg <message> send telegram notification
1574
1591
  p tg <message> --reply send message and wait for reply (blocking)
@@ -1949,6 +1966,20 @@ function capturePayloadText(res) {
1949
1966
  return res.output || "";
1950
1967
  }
1951
1968
 
1969
+ // capture a session (or glob/all) twice, intervalMs apart, and report whether
1970
+ // the rendered bottom-100 lines are stable ("done") or still changing
1971
+ // ("working"). shared by `p watch` and the flow engine's turn-completion check.
1972
+ async function captureStability(name, intervalMs) {
1973
+ const capture = () => sendCommand({ cmd: "capture", name, args: { lines: 100 } });
1974
+ const first = await capture();
1975
+ if (!first.ok) throw new Error(first.error || `failed to capture ${name}`);
1976
+ const firstHash = hashText(capturePayloadText(first));
1977
+ await sleep(intervalMs);
1978
+ const second = await capture();
1979
+ if (!second.ok) throw new Error(second.error || `failed to capture ${name}`);
1980
+ return firstHash === hashText(capturePayloadText(second)) ? "done" : "working";
1981
+ }
1982
+
1952
1983
  function expandHome(value) {
1953
1984
  if (!value) return value;
1954
1985
  if (value === "~") return homedir();
@@ -1967,6 +1998,104 @@ export function loadFlowConfig(configPath = "pty-mgr.config.json") {
1967
1998
  return { adapters: {}, flows: {} };
1968
1999
  }
1969
2000
 
2001
+ function xdgConfigHome() {
2002
+ return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
2003
+ }
2004
+
2005
+ export function userConfigPath() {
2006
+ return process.env.PTY_MGR_CONFIG || join(xdgConfigHome(), "pty-mgr", "config.json");
2007
+ }
2008
+
2009
+ function packagedDefaultsConfigPath() {
2010
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "pty-mgr.config.json");
2011
+ }
2012
+
2013
+ export function findProjectConfigPath(cwd = process.cwd()) {
2014
+ let dir = cwd;
2015
+ const home = homedir();
2016
+ while (true) {
2017
+ const candidate = join(dir, "pty-mgr.config.json");
2018
+ if (existsSync(candidate)) return candidate;
2019
+ if (existsSync(join(dir, ".git")) || dir === home) return null;
2020
+ const parent = dirname(dir);
2021
+ if (parent === dir) return null;
2022
+ dir = parent;
2023
+ }
2024
+ }
2025
+
2026
+ // merge flow configs low->high precedence; higher layers override by top-level key.
2027
+ // returns { adapters, flows, scopes } where scopes maps flow name -> layer it came from.
2028
+ export function resolveMergedConfig({ cwd = process.cwd(), explicitPath = null } = {}) {
2029
+ if (explicitPath) {
2030
+ const config = loadFlowConfig(explicitPath);
2031
+ const scopes = {};
2032
+ for (const name of Object.keys(config.flows || {})) scopes[name] = "config";
2033
+ return { adapters: config.adapters || {}, flows: config.flows || {}, scopes };
2034
+ }
2035
+ const layers = [];
2036
+ const defaultsPath = packagedDefaultsConfigPath();
2037
+ if (existsSync(defaultsPath)) layers.push(["default", loadFlowConfig(defaultsPath)]);
2038
+ const userPath = userConfigPath();
2039
+ if (existsSync(userPath)) layers.push(["user", loadFlowConfig(userPath)]);
2040
+ const projectPath = findProjectConfigPath(cwd);
2041
+ if (projectPath && projectPath !== defaultsPath) layers.push(["project", loadFlowConfig(projectPath)]);
2042
+
2043
+ const adapters = {};
2044
+ const flows = {};
2045
+ const scopes = {};
2046
+ for (const [scope, config] of layers) {
2047
+ Object.assign(adapters, config.adapters || {});
2048
+ for (const [name, flow] of Object.entries(config.flows || {})) {
2049
+ flows[name] = flow;
2050
+ scopes[name] = scope;
2051
+ }
2052
+ }
2053
+ return { adapters, flows, scopes };
2054
+ }
2055
+
2056
+ function exampleFlow() {
2057
+ return {
2058
+ agents: {
2059
+ writer: { kind: "codex", base: "flow-writer" },
2060
+ reviewer: { kind: "claude", base: "flow-reviewer" },
2061
+ },
2062
+ start: { to: "writer", template: "{task}" },
2063
+ turns: [
2064
+ {
2065
+ from: "writer",
2066
+ to: "reviewer",
2067
+ append:
2068
+ "Review what the other agent just did in this repository. Give specific, actionable feedback as a short numbered list, each item referencing file:line.",
2069
+ },
2070
+ {
2071
+ from: "reviewer",
2072
+ to: "writer",
2073
+ append:
2074
+ "Apply the feedback you agree with, note anything you skip and why, then report the final state. Original task: {goal}",
2075
+ },
2076
+ ],
2077
+ maxCycles: 1,
2078
+ watchInterval: "10s",
2079
+ settleMs: 1500,
2080
+ };
2081
+ }
2082
+
2083
+ function scaffoldFlowConfig(targetPath, name) {
2084
+ const dir = dirname(targetPath);
2085
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2086
+ const config = existsSync(targetPath) ? loadFlowConfig(targetPath) : { flows: {} };
2087
+ if (!config.flows) config.flows = {};
2088
+ if (config.flows[name]) {
2089
+ console.error(`flow already exists: ${name} (${targetPath})`);
2090
+ process.exit(1);
2091
+ }
2092
+ config.flows[name] = exampleFlow();
2093
+ writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n");
2094
+ console.log(`created flow "${name}" in ${targetPath}`);
2095
+ console.log(` edit: p open config${targetPath === userConfigPath() ? "" : " --local"}`);
2096
+ console.log(` run: p flow run ${name} --task "..."`);
2097
+ }
2098
+
1970
2099
  function walkJsonlFiles(root, files = []) {
1971
2100
  root = expandHome(root);
1972
2101
  if (!root || !existsSync(root)) return files;
@@ -2048,11 +2177,14 @@ export function transcriptRootsForKind(kind, config, cwd = process.cwd()) {
2048
2177
  .filter((root, index, roots) => existsSync(root) || index === roots.length - 1);
2049
2178
  }
2050
2179
 
2180
+ // flatten every .jsonl transcript under a kind's configured roots.
2181
+ function transcriptFiles(kind, config, cwd = process.cwd()) {
2182
+ return transcriptRootsForKind(kind, config, cwd).flatMap((root) => walkJsonlFiles(root));
2183
+ }
2184
+
2051
2185
  export function findNewestTranscript({ kind, cwd = process.cwd(), sinceMs = 0, config }) {
2052
2186
  const adapter = adapterForKind(kind, config);
2053
- const roots = transcriptRootsForKind(kind, config, cwd);
2054
- const candidates = roots
2055
- .flatMap((root) => walkJsonlFiles(root))
2187
+ const candidates = transcriptFiles(kind, config, cwd)
2056
2188
  .map((path) => {
2057
2189
  const mtimeMs = statSync(path).mtimeMs;
2058
2190
  const startedMs = transcriptStartedAtMs(path, adapter);
@@ -2195,8 +2327,7 @@ export function findTranscriptForSentMessage({
2195
2327
  }) {
2196
2328
  if (file) return findSentMessageInTranscript(file, kind, text, config);
2197
2329
 
2198
- const candidates = transcriptRootsForKind(kind, config, cwd)
2199
- .flatMap((root) => walkJsonlFiles(root))
2330
+ const candidates = transcriptFiles(kind, config, cwd)
2200
2331
  .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
2201
2332
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
2202
2333
 
@@ -2346,14 +2477,7 @@ async function sendFlowMessageConfirmed(meta, text, config, deps = {}) {
2346
2477
 
2347
2478
  async function watchFlowSession(session, intervalMs, deps = {}) {
2348
2479
  if (deps.watchSession) return deps.watchSession(session, intervalMs);
2349
- const first = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2350
- if (!first.ok) throw new Error(first.error || `failed to capture ${session}`);
2351
- const firstHash = hashText(capturePayloadText(first));
2352
- await sleep(intervalMs);
2353
- const second = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2354
- if (!second.ok) throw new Error(second.error || `failed to capture ${session}`);
2355
- const secondHash = hashText(capturePayloadText(second));
2356
- return firstHash === secondHash ? "done" : "working";
2480
+ return captureStability(session, intervalMs);
2357
2481
  }
2358
2482
 
2359
2483
  async function waitForFlowMessage({
@@ -2531,8 +2655,6 @@ function detectRcFile() {
2531
2655
  }
2532
2656
 
2533
2657
  async function runSetup() {
2534
- const { appendFileSync, readFileSync, writeFileSync } = await import("node:fs");
2535
-
2536
2658
  console.log("pty-mgr setup");
2537
2659
  console.log("wrap CLI tools in managed PTY sessions.\n");
2538
2660
  console.log("when you type a wrapped command (e.g. claude), pty-mgr will:");
@@ -2650,20 +2772,55 @@ async function cli() {
2650
2772
  return;
2651
2773
  }
2652
2774
 
2775
+ if (command === "open") {
2776
+ const sub = args[0];
2777
+ if (sub !== "config") {
2778
+ console.error("usage: p open config [editor] [--local|--defaults]");
2779
+ process.exit(1);
2780
+ }
2781
+ const { flags: openFlags, rest: openRest } = parseFlagArgs(args, 1);
2782
+ const editor = openRest[0] || process.env.VISUAL || process.env.EDITOR || "code";
2783
+ let dir;
2784
+ if (openFlags.defaults) {
2785
+ dir = dirname(packagedDefaultsConfigPath());
2786
+ } else if (openFlags.local) {
2787
+ const projectPath = findProjectConfigPath(process.cwd());
2788
+ dir = projectPath ? dirname(projectPath) : process.cwd();
2789
+ } else {
2790
+ dir = dirname(userConfigPath());
2791
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2792
+ }
2793
+ console.log(`opening ${dir} with ${editor}`);
2794
+ await new Promise((resolve) => {
2795
+ const child = spawnChild(editor, [dir], { stdio: "inherit" });
2796
+ child.on("error", (err) => {
2797
+ console.error(`could not launch ${editor}: ${err.message}`);
2798
+ resolve();
2799
+ });
2800
+ child.on("exit", () => resolve());
2801
+ });
2802
+ return;
2803
+ }
2804
+
2653
2805
  if (command === "flow") {
2654
2806
  const subcommand = args[0];
2655
- const { flags } = parseFlagArgs(args, 1);
2656
- const configPath = flags.config || "pty-mgr.config.json";
2807
+ const { flags, rest } = parseFlagArgs(args, 1);
2808
+ if (subcommand === "new") {
2809
+ const name = rest[0] || "example";
2810
+ const target = flags.global ? userConfigPath() : join(process.cwd(), "pty-mgr.config.json");
2811
+ scaffoldFlowConfig(target, name);
2812
+ return;
2813
+ }
2657
2814
  if (subcommand === "list") {
2658
- const config = loadFlowConfig(configPath);
2659
- const names = Object.keys(config.flows || {});
2815
+ const { flows, scopes } = resolveMergedConfig({ cwd: process.cwd(), explicitPath: flags.config });
2816
+ const names = Object.keys(flows);
2660
2817
  if (names.length === 0) {
2661
2818
  console.log("no flows configured");
2662
2819
  } else {
2663
2820
  for (const name of names) {
2664
- console.log(name);
2821
+ console.log(flags.config ? name : `${name} [${scopes[name]}]`);
2665
2822
  if (flags.verbose) {
2666
- const flow = config.flows[name] || {};
2823
+ const flow = flows[name] || {};
2667
2824
  for (const [alias, agent] of Object.entries(flow.agents || {})) {
2668
2825
  console.log(` ${alias} -> ${flowAgentKind(agent)}`);
2669
2826
  }
@@ -2680,8 +2837,8 @@ async function cli() {
2680
2837
  console.error("usage: pty-mgr flow show <name>");
2681
2838
  process.exit(1);
2682
2839
  }
2683
- const config = loadFlowConfig(configPath);
2684
- const flow = config.flows?.[name];
2840
+ const { flows } = resolveMergedConfig({ cwd: process.cwd(), explicitPath: flags.config });
2841
+ const flow = flows[name];
2685
2842
  if (!flow) {
2686
2843
  console.error(`flow not found: ${name}`);
2687
2844
  process.exit(1);
@@ -2716,19 +2873,20 @@ async function cli() {
2716
2873
  console.error("flow run requires --task <text>");
2717
2874
  process.exit(1);
2718
2875
  }
2876
+ const runCwd = runFlags.cwd || process.cwd();
2877
+ const merged = resolveMergedConfig({ cwd: runCwd, explicitPath: runFlags.config || flags.config });
2719
2878
  try {
2720
2879
  const result = await runFlowWorkflow({
2721
2880
  workflow,
2722
2881
  task,
2723
2882
  goal: runFlags.goal,
2724
- config: runFlags.config || configPath,
2725
- cwd: runFlags.cwd || process.cwd(),
2883
+ cwd: runCwd,
2726
2884
  maxCycles: runFlags["max-cycles"],
2727
2885
  watchInterval: runFlags["watch-interval"],
2728
2886
  settleMs: runFlags["settle-ms"],
2729
2887
  timeoutMs: runFlags["timeout-ms"],
2730
2888
  intervalMs: runFlags["interval-ms"],
2731
- });
2889
+ }, { config: merged });
2732
2890
  console.log(JSON.stringify(result, null, 2));
2733
2891
  } catch (err) {
2734
2892
  console.error(err.message);
@@ -2773,12 +2931,11 @@ async function cli() {
2773
2931
  if (command === "daemon") {
2774
2932
  // fork into background as a true daemon (survives terminal close)
2775
2933
  if (!process.env.__PTY_DAEMON_CHILD) {
2776
- const { spawn: cpSpawn } = await import("node:child_process");
2777
2934
  // re-exec ourselves with the same args
2778
2935
  // process.execPath = bun (dev) or the compiled binary
2779
2936
  // filter out internal /$bunfs/ paths from argv
2780
2937
  const realArgs = process.argv.slice(1).filter(a => !a.startsWith("/$bunfs/"));
2781
- const child = cpSpawn(process.execPath, realArgs, {
2938
+ const child = spawnChild(process.execPath, realArgs, {
2782
2939
  detached: true,
2783
2940
  stdio: ["ignore", "ignore", "ignore", "ipc"],
2784
2941
  env: { ...process.env, __PTY_DAEMON_CHILD: "1" },
@@ -2812,7 +2969,6 @@ async function cli() {
2812
2969
  const target = args[0]; // "all" or undefined (= current daemon)
2813
2970
  if (target === "all") {
2814
2971
  // find all pty-manager sockets and shut them down
2815
- const { readdirSync } = await import("node:fs");
2816
2972
  const dir = join(homedir(), ".pty-manager");
2817
2973
  let socks = [];
2818
2974
  try {
@@ -2900,20 +3056,7 @@ async function cli() {
2900
3056
  }
2901
3057
 
2902
3058
  try {
2903
- const first = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
2904
- if (!first.ok) {
2905
- console.error("error:", first.error);
2906
- process.exit(1);
2907
- }
2908
- const firstHash = hashText(capturePayloadText(first));
2909
- await sleep(intervalMs);
2910
- const second = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
2911
- if (!second.ok) {
2912
- console.error("error:", second.error);
2913
- process.exit(1);
2914
- }
2915
- const secondHash = hashText(capturePayloadText(second));
2916
- console.log(firstHash === secondHash ? "done" : "working");
3059
+ console.log(await captureStability(name, intervalMs));
2917
3060
  process.exit(0);
2918
3061
  } catch (err) {
2919
3062
  console.error(err.message);
@@ -3031,6 +3174,7 @@ async function cli() {
3031
3174
  console.log(`pty-manager daemon (${st.name})`);
3032
3175
  console.log(` pid: ${st.pid}`);
3033
3176
  console.log(` socket: ${st.socket}`);
3177
+ console.log(` cwd: ${st.cwd}`);
3034
3178
  console.log(` uptime: ${st.uptime}`);
3035
3179
  console.log(` sessions: ${st.sessions.alive} alive, ${st.sessions.dead} dead, ${st.sessions.total} total`);
3036
3180
  console.log(` screen: ${st.config.cols}x${st.config.rows}`);
@@ -3119,7 +3263,10 @@ const isMain = _isBunCompiled || (_basename1 && _pat.test(_basename1)) || (_base
3119
3263
 
3120
3264
  if (isMain) {
3121
3265
  cli().catch((err) => {
3122
- console.error(err);
3266
+ // message only -- a raw error object prints a stack trace for what are
3267
+ // usually plain user-facing failures (session not found, daemon down)
3268
+ console.error("error:", err.message);
3269
+ if (process.env.PTY_MGR_DEBUG) console.error(err.stack);
3123
3270
  process.exit(1);
3124
3271
  });
3125
3272
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentiko/pty-mgr",
3
- "version": "1.3.1",
3
+ "version": "1.4.2",
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.3.1",
30
- "@mentiko/pty-mgr-linux-arm64": "1.3.1",
31
- "@mentiko/pty-mgr-darwin-x64": "1.3.1",
32
- "@mentiko/pty-mgr-darwin-arm64": "1.3.1"
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"
33
33
  },
34
34
  "engines": {
35
35
  "bun": ">=1.0"