@mentiko/pty-mgr 1.4.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 +136 -172
  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";
@@ -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.2";
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);
@@ -776,9 +756,11 @@ function startDaemon() {
776
756
  });
777
757
 
778
758
  conn.on("data", async (data) => {
779
- // 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.
780
762
  if (attached) {
781
- attached.write(data.toString());
763
+ try { attached.write(data.toString()); } catch {}
782
764
  return;
783
765
  }
784
766
 
@@ -878,15 +860,9 @@ function startDaemon() {
878
860
 
879
861
  async function tgSend(chatId, text) {
880
862
  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
- });
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));
890
866
  }
891
867
  }
892
868
 
@@ -1088,6 +1064,15 @@ export function buildSafeEnv(extra) {
1088
1064
  return safeEnv;
1089
1065
  }
1090
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
+
1091
1076
  // POSIX single-quote escaping: wrap in '...' and replace each ' with '\''.
1092
1077
  // Makes a token literal to the shell -- no expansion, no command substitution,
1093
1078
  // no word splitting. Used to build the `zsh -lic` command line for `wrap`.
@@ -1095,6 +1080,17 @@ export function shellQuote(arg) {
1095
1080
  return `'${String(arg).replace(/'/g, "'\\''")}'`;
1096
1081
  }
1097
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
+
1098
1094
  async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1099
1095
  const { cmd, name, args } = req;
1100
1096
 
@@ -1153,8 +1149,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1153
1149
  const cmdToRun = args?.cmd || "zsh";
1154
1150
  const cmdArgs = args?.args || [];
1155
1151
  // 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));
1152
+ const cols = clampCols(args?.cols, config.cols);
1153
+ const rows = clampRows(args?.rows, config.rows);
1158
1154
  const safeEnv = buildSafeEnv(args?.env);
1159
1155
  safeEnv.PTY_MGR_SESSION = name;
1160
1156
  const opts = {
@@ -1165,14 +1161,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1165
1161
  };
1166
1162
  mgr.spawn(name, cmdToRun, cmdArgs, opts);
1167
1163
  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
- }
1164
+ if (args?.log) res.logPath = startDefaultLog(mgr, name);
1176
1165
  return res;
1177
1166
  }
1178
1167
  case "wrap": {
@@ -1195,8 +1184,8 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1195
1184
  if (maxNum === 0) maxNum = 1;
1196
1185
  const nextName = `${baseName}-${maxNum}`;
1197
1186
 
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));
1187
+ const cols = clampCols(args?.cols, config.cols);
1188
+ const rows = clampRows(args?.rows, config.rows);
1200
1189
 
1201
1190
  // wrap spawns through user's login shell so shell functions/aliases work
1202
1191
  // (like tmux does). this means glm, nvm, conda, etc. all resolve.
@@ -1223,13 +1212,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1223
1212
 
1224
1213
  mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
1225
1214
  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
- }
1215
+ if (args?.log) res.logPath = startDefaultLog(mgr, nextName);
1233
1216
  return res;
1234
1217
  }
1235
1218
  case "send": {
@@ -1346,11 +1329,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1346
1329
  const msg = req.args?.message;
1347
1330
  if (!msg) return { ok: false, error: "message required" };
1348
1331
  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
- });
1332
+ await telegramSend(token, chatId, msg);
1354
1333
  return { ok: true };
1355
1334
  }
1356
1335
  case "tg-wait": {
@@ -1360,11 +1339,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1360
1339
  if (tgState.waiter) return { ok: false, error: "ALREADY_WAITING" };
1361
1340
  const msg = req.args?.message;
1362
1341
  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
- });
1342
+ await telegramSend(token, chatId, msg);
1368
1343
  const sessionName = req.args?.session;
1369
1344
  return new Promise((resolve) => {
1370
1345
  const timer = setTimeout(() => {
@@ -1389,58 +1364,55 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1389
1364
  }
1390
1365
 
1391
1366
  /**
1392
- * 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.
1393
1371
  */
1394
- function sendCommandTo(sock, req) {
1372
+ function requestSocket(sock, req, notRunningMsg) {
1395
1373
  return new Promise((resolve, reject) => {
1396
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
+ };
1397
1387
  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
- }
1388
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") settle(reject, new Error(notRunningMsg));
1389
+ else settle(reject, err);
1403
1390
  });
1404
1391
  conn.on("connect", () => {
1405
- conn.write(JSON.stringify(req) + "\n");
1392
+ try { conn.write(JSON.stringify(req) + "\n"); }
1393
+ catch (err) { settle(reject, err); }
1406
1394
  });
1407
- let buf = "";
1408
1395
  conn.on("data", (data) => {
1409
1396
  buf += data.toString();
1410
1397
  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
- }
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));
1416
1404
  });
1417
1405
  });
1418
1406
  }
1419
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.
1420
1414
  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
- });
1415
+ return requestSocket(SOCKET_PATH, req, "daemon not running. start with: pty-mgr daemon");
1444
1416
  }
1445
1417
 
1446
1418
  /**
@@ -1994,6 +1966,20 @@ function capturePayloadText(res) {
1994
1966
  return res.output || "";
1995
1967
  }
1996
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
+
1997
1983
  function expandHome(value) {
1998
1984
  if (!value) return value;
1999
1985
  if (value === "~") return homedir();
@@ -2191,11 +2177,14 @@ export function transcriptRootsForKind(kind, config, cwd = process.cwd()) {
2191
2177
  .filter((root, index, roots) => existsSync(root) || index === roots.length - 1);
2192
2178
  }
2193
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
+
2194
2185
  export function findNewestTranscript({ kind, cwd = process.cwd(), sinceMs = 0, config }) {
2195
2186
  const adapter = adapterForKind(kind, config);
2196
- const roots = transcriptRootsForKind(kind, config, cwd);
2197
- const candidates = roots
2198
- .flatMap((root) => walkJsonlFiles(root))
2187
+ const candidates = transcriptFiles(kind, config, cwd)
2199
2188
  .map((path) => {
2200
2189
  const mtimeMs = statSync(path).mtimeMs;
2201
2190
  const startedMs = transcriptStartedAtMs(path, adapter);
@@ -2338,8 +2327,7 @@ export function findTranscriptForSentMessage({
2338
2327
  }) {
2339
2328
  if (file) return findSentMessageInTranscript(file, kind, text, config);
2340
2329
 
2341
- const candidates = transcriptRootsForKind(kind, config, cwd)
2342
- .flatMap((root) => walkJsonlFiles(root))
2330
+ const candidates = transcriptFiles(kind, config, cwd)
2343
2331
  .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
2344
2332
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
2345
2333
 
@@ -2489,14 +2477,7 @@ async function sendFlowMessageConfirmed(meta, text, config, deps = {}) {
2489
2477
 
2490
2478
  async function watchFlowSession(session, intervalMs, deps = {}) {
2491
2479
  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";
2480
+ return captureStability(session, intervalMs);
2500
2481
  }
2501
2482
 
2502
2483
  async function waitForFlowMessage({
@@ -2674,8 +2655,6 @@ function detectRcFile() {
2674
2655
  }
2675
2656
 
2676
2657
  async function runSetup() {
2677
- const { appendFileSync, readFileSync, writeFileSync } = await import("node:fs");
2678
-
2679
2658
  console.log("pty-mgr setup");
2680
2659
  console.log("wrap CLI tools in managed PTY sessions.\n");
2681
2660
  console.log("when you type a wrapped command (e.g. claude), pty-mgr will:");
@@ -2952,12 +2931,11 @@ async function cli() {
2952
2931
  if (command === "daemon") {
2953
2932
  // fork into background as a true daemon (survives terminal close)
2954
2933
  if (!process.env.__PTY_DAEMON_CHILD) {
2955
- const { spawn: cpSpawn } = await import("node:child_process");
2956
2934
  // re-exec ourselves with the same args
2957
2935
  // process.execPath = bun (dev) or the compiled binary
2958
2936
  // filter out internal /$bunfs/ paths from argv
2959
2937
  const realArgs = process.argv.slice(1).filter(a => !a.startsWith("/$bunfs/"));
2960
- const child = cpSpawn(process.execPath, realArgs, {
2938
+ const child = spawnChild(process.execPath, realArgs, {
2961
2939
  detached: true,
2962
2940
  stdio: ["ignore", "ignore", "ignore", "ipc"],
2963
2941
  env: { ...process.env, __PTY_DAEMON_CHILD: "1" },
@@ -2991,7 +2969,6 @@ async function cli() {
2991
2969
  const target = args[0]; // "all" or undefined (= current daemon)
2992
2970
  if (target === "all") {
2993
2971
  // find all pty-manager sockets and shut them down
2994
- const { readdirSync } = await import("node:fs");
2995
2972
  const dir = join(homedir(), ".pty-manager");
2996
2973
  let socks = [];
2997
2974
  try {
@@ -3079,20 +3056,7 @@ async function cli() {
3079
3056
  }
3080
3057
 
3081
3058
  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");
3059
+ console.log(await captureStability(name, intervalMs));
3096
3060
  process.exit(0);
3097
3061
  } catch (err) {
3098
3062
  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.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.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.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"