@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.
- package/lib/pty-manager.mjs +350 -203
- 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";
|
|
@@ -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.
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
-
*
|
|
198
|
-
* reads cells individually
|
|
199
|
-
*
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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 =
|
|
288
|
-
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"
|
|
332
|
-
* "jsonl"
|
|
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
|
-
*
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
866
|
-
|
|
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 =
|
|
1139
|
-
const 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
|
-
|
|
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 =
|
|
1180
|
-
const 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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
|
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
|
-
|
|
1469
|
+
failed = true;
|
|
1465
1470
|
conn.end();
|
|
1466
1471
|
reject(new Error(ack.error));
|
|
1467
1472
|
return;
|
|
1468
1473
|
}
|
|
1469
1474
|
} catch {
|
|
1470
|
-
|
|
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("
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2659
|
-
const names = Object.keys(
|
|
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 =
|
|
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
|
|
2684
|
-
const flow =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
30
|
-
"@mentiko/pty-mgr-linux-arm64": "1.
|
|
31
|
-
"@mentiko/pty-mgr-darwin-x64": "1.
|
|
32
|
-
"@mentiko/pty-mgr-darwin-arm64": "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"
|