@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.
- package/lib/pty-manager.mjs +136 -172
- package/package.json +5 -5
package/lib/pty-manager.mjs
CHANGED
|
@@ -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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
*
|
|
201
|
-
* reads cells individually
|
|
202
|
-
*
|
|
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 =
|
|
299
|
-
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"
|
|
343
|
-
* "jsonl"
|
|
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
|
-
*
|
|
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
|
-
|
|
883
|
-
|
|
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 =
|
|
1157
|
-
const 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
|
-
|
|
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 =
|
|
1199
|
-
const 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
30
|
-
"@mentiko/pty-mgr-linux-arm64": "1.4.
|
|
31
|
-
"@mentiko/pty-mgr-darwin-x64": "1.4.
|
|
32
|
-
"@mentiko/pty-mgr-darwin-arm64": "1.4.
|
|
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"
|