@mentiko/pty-mgr 1.3.1 → 1.4.1

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 +218 -35
  2. package/package.json +5 -5
@@ -38,10 +38,13 @@ import {
38
38
  readFileSync,
39
39
  readdirSync,
40
40
  statSync,
41
+ writeFileSync,
41
42
  } from "node:fs";
43
+ import { fileURLToPath } from "node:url";
44
+ import { spawn as spawnChild } from "node:child_process";
42
45
 
43
46
  // kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
44
- export const VERSION = "1.3.1";
47
+ export const VERSION = "1.4.1";
45
48
  import xterm from "@xterm/headless";
46
49
  import { SerializeAddon } from "@xterm/addon-serialize";
47
50
 
@@ -210,14 +213,17 @@ class PtySession {
210
213
  if (!line) { lines.push(""); continue; }
211
214
 
212
215
  let out = "";
216
+ let keep = 0; // output length up to the last cell worth keeping
213
217
  let prevFg = -1, prevBg = -1, prevBold = false, prevDim = false;
214
218
  let prevItalic = false, prevUnder = false, prevInverse = false;
215
219
 
216
220
  for (let x = 0; x < line.length; x++) {
217
221
  const cell = line.getCell(x);
218
222
  if (!cell) continue;
219
- const ch = cell.getChars();
220
- if (!ch) continue;
223
+ if (cell.getWidth() === 0) continue; // wide-char continuation cell
224
+ // null cells (never written; TUIs skip them with cursor moves)
225
+ // must still occupy a column, or text collapses together
226
+ const ch = cell.getChars() || " ";
221
227
 
222
228
  const fg = cell.getFgColor();
223
229
  const bg = cell.getBgColor();
@@ -258,8 +264,13 @@ class PtySession {
258
264
  prevItalic = italic; prevUnder = under; prevInverse = inverse;
259
265
  }
260
266
  out += ch;
267
+ if (ch !== " " || fgMode !== 0 || bgMode !== 0 ||
268
+ bold || dim || italic || under || inverse) {
269
+ keep = out.length;
270
+ }
261
271
  }
262
- if (prevFg !== -1 || prevBold) out += "\x1b[0m";
272
+ out = out.slice(0, keep); // drop trailing unstyled blanks
273
+ if (keep > 0 && (prevFg !== -1 || prevBold)) out += "\x1b[0m";
263
274
  lines.push(out);
264
275
  }
265
276
 
@@ -790,15 +801,18 @@ function startDaemon() {
790
801
  const session = mgr.get(req.name);
791
802
  const cols = session.terminal.cols;
792
803
  const rows = session.terminal.rows;
793
- // send initial ack with terminal size
794
- conn.write(JSON.stringify({ ok: true, mode: "attach", cols, rows }) + "\n");
795
-
796
- // send current buffer as plain text, then SIGWINCH for colored redraw
797
- conn.write("\x1b[2J\x1b[H");
798
- const screen = session.capture(session.terminal.rows);
799
- if (screen) conn.write(screen.replace(/\n/g, "\r\n") + "\r\n");
800
-
801
- // force TUI apps to redraw on top
804
+ const alt = session.terminal.buffer.active.type === "alternate";
805
+ // send initial ack with terminal size + buffer state
806
+ conn.write(JSON.stringify({ ok: true, mode: "attach", cols, rows, alt }) + "\n");
807
+
808
+ // replay the session's full terminal state: scrollback, screen,
809
+ // colors, cursor, modes. history lands in the client's own
810
+ // scrollback so it stays scrollable. if the session is inside a
811
+ // TUI, the serializer emits the alt-screen switch itself and the
812
+ // client pops back out on detach.
813
+ conn.write("\x1b[2J\x1b[H" + session.captureAnsi());
814
+
815
+ // nudge the app to repaint so live output aligns with the replay
802
816
  if (session.childPid) {
803
817
  try { process.kill(session.childPid, "SIGWINCH"); } catch {}
804
818
  }
@@ -812,7 +826,10 @@ function startDaemon() {
812
826
  // when session exits, notify and close
813
827
  const onExit = () => {
814
828
  try {
815
- conn.write("\r\n[session exited]\r\n");
829
+ if (session.terminal.buffer.active.type === "alternate") {
830
+ conn.write("\x1b[?1049l");
831
+ }
832
+ conn.write("\x1b[0m\r\n[session exited]\r\n");
816
833
  conn.end();
817
834
  } catch {}
818
835
  };
@@ -1093,6 +1110,7 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1093
1110
  name: DAEMON_NAME,
1094
1111
  pid: process.pid,
1095
1112
  socket: SOCKET_PATH,
1113
+ cwd: process.cwd(),
1096
1114
  startedAt: daemonStartedAt.toISOString(),
1097
1115
  uptimeMs,
1098
1116
  uptime: formatUptime(uptimeMs),
@@ -1147,9 +1165,10 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1147
1165
  };
1148
1166
  mgr.spawn(name, cmdToRun, cmdArgs, opts);
1149
1167
  const res = { ok: true, name, pid: mgr.pid(name) };
1150
- // auto-start logging if --log flag
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.
1151
1170
  if (args?.log) {
1152
- const logDir = join(process.cwd(), "agents", "logs");
1171
+ const logDir = join(mgr.get(name).cwd, "agents", "logs");
1153
1172
  const logPath = join(logDir, `${name}-${Date.now()}.jsonl`);
1154
1173
  mgr.get(name).startLog(logPath, "jsonl");
1155
1174
  res.logPath = logPath;
@@ -1301,10 +1320,11 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1301
1320
  const path = session.stopLog();
1302
1321
  return { ok: true, stopped: true, path };
1303
1322
  }
1304
- // default log dir: ./agents/logs/
1323
+ // default log dir: <session cwd>/agents/logs/ -- follow the session's
1324
+ // own dir, not the daemon's frozen cwd.
1305
1325
  const format = args?.format || "jsonl";
1306
1326
  const ext = format === "jsonl" ? "jsonl" : format === "rendered" ? "log" : "raw";
1307
- const logDir = args?.dir || join(process.cwd(), "agents", "logs");
1327
+ const logDir = args?.dir || join(session.cwd, "agents", "logs");
1308
1328
  const ts = Date.now();
1309
1329
  const logPath = args?.path || join(logDir, `${name}-${ts}.${ext}`);
1310
1330
  session.startLog(logPath, format);
@@ -1431,8 +1451,12 @@ function sendCommand(req) {
1431
1451
  function attachToSession(name) {
1432
1452
  return new Promise((resolve, reject) => {
1433
1453
  const conn = createConnection(SOCKET_PATH);
1454
+ // set on any pre-attach failure so the close handler doesn't run the
1455
+ // detach cleanup (stray "detached" + terminal resets after an error)
1456
+ let failed = false;
1434
1457
 
1435
1458
  conn.on("error", (err) => {
1459
+ failed = true;
1436
1460
  if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
1437
1461
  reject(new Error("daemon not running. start with: pty-mgr daemon"));
1438
1462
  } else {
@@ -1447,6 +1471,15 @@ function attachToSession(name) {
1447
1471
  let gotAck = false;
1448
1472
  let headerBuf = "";
1449
1473
 
1474
+ // last-seen alt-screen state: detach pops the client back to its
1475
+ // normal screen only if the session left it in the alternate buffer
1476
+ let altActive = false;
1477
+ const trackAlt = (chunk) => {
1478
+ const h = chunk.lastIndexOf("\x1b[?1049h");
1479
+ const l = chunk.lastIndexOf("\x1b[?1049l");
1480
+ if (h !== -1 || l !== -1) altActive = h > l;
1481
+ };
1482
+
1450
1483
  conn.on("data", (data) => {
1451
1484
  if (!gotAck) {
1452
1485
  // first line is the JSON ack
@@ -1461,19 +1494,20 @@ function attachToSession(name) {
1461
1494
  try {
1462
1495
  ack = JSON.parse(ackStr);
1463
1496
  if (!ack.ok) {
1464
- console.error("error:", ack.error);
1497
+ failed = true;
1465
1498
  conn.end();
1466
1499
  reject(new Error(ack.error));
1467
1500
  return;
1468
1501
  }
1469
1502
  } catch {
1470
- console.error("bad ack from daemon");
1503
+ failed = true;
1471
1504
  conn.end();
1472
- reject(new Error("bad ack"));
1505
+ reject(new Error("bad ack from daemon"));
1473
1506
  return;
1474
1507
  }
1475
1508
 
1476
1509
  gotAck = true;
1510
+ altActive = !!ack.alt;
1477
1511
 
1478
1512
  // resize client terminal to match session
1479
1513
  if (ack.cols && ack.rows) {
@@ -1489,6 +1523,7 @@ function attachToSession(name) {
1489
1523
 
1490
1524
  // write any remaining data after the ack
1491
1525
  if (remainder) {
1526
+ trackAlt(remainder);
1492
1527
  process.stdout.write(remainder);
1493
1528
  }
1494
1529
 
@@ -1506,6 +1541,7 @@ function attachToSession(name) {
1506
1541
  }
1507
1542
 
1508
1543
  // streaming mode: write pty output to terminal
1544
+ trackAlt(data);
1509
1545
  process.stdout.write(data);
1510
1546
  });
1511
1547
 
@@ -1515,15 +1551,22 @@ function attachToSession(name) {
1515
1551
 
1516
1552
  let detached = false;
1517
1553
  function detach() {
1518
- if (detached) return;
1554
+ if (detached || failed) return;
1519
1555
  detached = true;
1520
1556
  if (process.stdin.isRaw) {
1521
1557
  process.stdin.setRawMode(false);
1522
1558
  process.stdin.pause();
1523
1559
  process.stdin.removeAllListeners("data");
1524
1560
  }
1561
+ // pop the alt screen only if the session left us in one, then
1562
+ // reset SGR/cursor and any modes the replayed TUI enabled
1563
+ // (keypad, cursor keys, bracketed paste, mouse tracking)
1564
+ if (altActive) process.stdout.write("\x1b[?1049l");
1565
+ process.stdout.write(
1566
+ "\x1b[0m\x1b[?25h\x1b>\x1b[?1l\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?9l"
1567
+ );
1525
1568
  conn.end();
1526
- console.log("\r\ndetached");
1569
+ console.log("detached");
1527
1570
  resolve();
1528
1571
  }
1529
1572
  });
@@ -1569,6 +1612,8 @@ usage:
1569
1612
  p flow list [--verbose] [--config file] list configured agent workflows
1570
1613
  p flow show <name> [--config file] show one configured agent workflow
1571
1614
  p flow run <name> --task <text> run a configured agent workflow
1615
+ p flow new [name] [--global] scaffold an example flow (project cwd, or --global user config)
1616
+ p open config [editor] open config dir in editor (--local project, --defaults packaged)
1572
1617
  p demo run self-test (no daemon needed)
1573
1618
  p tg <message> send telegram notification
1574
1619
  p tg <message> --reply send message and wait for reply (blocking)
@@ -1967,6 +2012,104 @@ export function loadFlowConfig(configPath = "pty-mgr.config.json") {
1967
2012
  return { adapters: {}, flows: {} };
1968
2013
  }
1969
2014
 
2015
+ function xdgConfigHome() {
2016
+ return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
2017
+ }
2018
+
2019
+ export function userConfigPath() {
2020
+ return process.env.PTY_MGR_CONFIG || join(xdgConfigHome(), "pty-mgr", "config.json");
2021
+ }
2022
+
2023
+ function packagedDefaultsConfigPath() {
2024
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "pty-mgr.config.json");
2025
+ }
2026
+
2027
+ export function findProjectConfigPath(cwd = process.cwd()) {
2028
+ let dir = cwd;
2029
+ const home = homedir();
2030
+ while (true) {
2031
+ const candidate = join(dir, "pty-mgr.config.json");
2032
+ if (existsSync(candidate)) return candidate;
2033
+ if (existsSync(join(dir, ".git")) || dir === home) return null;
2034
+ const parent = dirname(dir);
2035
+ if (parent === dir) return null;
2036
+ dir = parent;
2037
+ }
2038
+ }
2039
+
2040
+ // merge flow configs low->high precedence; higher layers override by top-level key.
2041
+ // returns { adapters, flows, scopes } where scopes maps flow name -> layer it came from.
2042
+ export function resolveMergedConfig({ cwd = process.cwd(), explicitPath = null } = {}) {
2043
+ if (explicitPath) {
2044
+ const config = loadFlowConfig(explicitPath);
2045
+ const scopes = {};
2046
+ for (const name of Object.keys(config.flows || {})) scopes[name] = "config";
2047
+ return { adapters: config.adapters || {}, flows: config.flows || {}, scopes };
2048
+ }
2049
+ const layers = [];
2050
+ const defaultsPath = packagedDefaultsConfigPath();
2051
+ if (existsSync(defaultsPath)) layers.push(["default", loadFlowConfig(defaultsPath)]);
2052
+ const userPath = userConfigPath();
2053
+ if (existsSync(userPath)) layers.push(["user", loadFlowConfig(userPath)]);
2054
+ const projectPath = findProjectConfigPath(cwd);
2055
+ if (projectPath && projectPath !== defaultsPath) layers.push(["project", loadFlowConfig(projectPath)]);
2056
+
2057
+ const adapters = {};
2058
+ const flows = {};
2059
+ const scopes = {};
2060
+ for (const [scope, config] of layers) {
2061
+ Object.assign(adapters, config.adapters || {});
2062
+ for (const [name, flow] of Object.entries(config.flows || {})) {
2063
+ flows[name] = flow;
2064
+ scopes[name] = scope;
2065
+ }
2066
+ }
2067
+ return { adapters, flows, scopes };
2068
+ }
2069
+
2070
+ function exampleFlow() {
2071
+ return {
2072
+ agents: {
2073
+ writer: { kind: "codex", base: "flow-writer" },
2074
+ reviewer: { kind: "claude", base: "flow-reviewer" },
2075
+ },
2076
+ start: { to: "writer", template: "{task}" },
2077
+ turns: [
2078
+ {
2079
+ from: "writer",
2080
+ to: "reviewer",
2081
+ append:
2082
+ "Review what the other agent just did in this repository. Give specific, actionable feedback as a short numbered list, each item referencing file:line.",
2083
+ },
2084
+ {
2085
+ from: "reviewer",
2086
+ to: "writer",
2087
+ append:
2088
+ "Apply the feedback you agree with, note anything you skip and why, then report the final state. Original task: {goal}",
2089
+ },
2090
+ ],
2091
+ maxCycles: 1,
2092
+ watchInterval: "10s",
2093
+ settleMs: 1500,
2094
+ };
2095
+ }
2096
+
2097
+ function scaffoldFlowConfig(targetPath, name) {
2098
+ const dir = dirname(targetPath);
2099
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2100
+ const config = existsSync(targetPath) ? loadFlowConfig(targetPath) : { flows: {} };
2101
+ if (!config.flows) config.flows = {};
2102
+ if (config.flows[name]) {
2103
+ console.error(`flow already exists: ${name} (${targetPath})`);
2104
+ process.exit(1);
2105
+ }
2106
+ config.flows[name] = exampleFlow();
2107
+ writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n");
2108
+ console.log(`created flow "${name}" in ${targetPath}`);
2109
+ console.log(` edit: p open config${targetPath === userConfigPath() ? "" : " --local"}`);
2110
+ console.log(` run: p flow run ${name} --task "..."`);
2111
+ }
2112
+
1970
2113
  function walkJsonlFiles(root, files = []) {
1971
2114
  root = expandHome(root);
1972
2115
  if (!root || !existsSync(root)) return files;
@@ -2650,20 +2793,55 @@ async function cli() {
2650
2793
  return;
2651
2794
  }
2652
2795
 
2796
+ if (command === "open") {
2797
+ const sub = args[0];
2798
+ if (sub !== "config") {
2799
+ console.error("usage: p open config [editor] [--local|--defaults]");
2800
+ process.exit(1);
2801
+ }
2802
+ const { flags: openFlags, rest: openRest } = parseFlagArgs(args, 1);
2803
+ const editor = openRest[0] || process.env.VISUAL || process.env.EDITOR || "code";
2804
+ let dir;
2805
+ if (openFlags.defaults) {
2806
+ dir = dirname(packagedDefaultsConfigPath());
2807
+ } else if (openFlags.local) {
2808
+ const projectPath = findProjectConfigPath(process.cwd());
2809
+ dir = projectPath ? dirname(projectPath) : process.cwd();
2810
+ } else {
2811
+ dir = dirname(userConfigPath());
2812
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2813
+ }
2814
+ console.log(`opening ${dir} with ${editor}`);
2815
+ await new Promise((resolve) => {
2816
+ const child = spawnChild(editor, [dir], { stdio: "inherit" });
2817
+ child.on("error", (err) => {
2818
+ console.error(`could not launch ${editor}: ${err.message}`);
2819
+ resolve();
2820
+ });
2821
+ child.on("exit", () => resolve());
2822
+ });
2823
+ return;
2824
+ }
2825
+
2653
2826
  if (command === "flow") {
2654
2827
  const subcommand = args[0];
2655
- const { flags } = parseFlagArgs(args, 1);
2656
- const configPath = flags.config || "pty-mgr.config.json";
2828
+ const { flags, rest } = parseFlagArgs(args, 1);
2829
+ if (subcommand === "new") {
2830
+ const name = rest[0] || "example";
2831
+ const target = flags.global ? userConfigPath() : join(process.cwd(), "pty-mgr.config.json");
2832
+ scaffoldFlowConfig(target, name);
2833
+ return;
2834
+ }
2657
2835
  if (subcommand === "list") {
2658
- const config = loadFlowConfig(configPath);
2659
- const names = Object.keys(config.flows || {});
2836
+ const { flows, scopes } = resolveMergedConfig({ cwd: process.cwd(), explicitPath: flags.config });
2837
+ const names = Object.keys(flows);
2660
2838
  if (names.length === 0) {
2661
2839
  console.log("no flows configured");
2662
2840
  } else {
2663
2841
  for (const name of names) {
2664
- console.log(name);
2842
+ console.log(flags.config ? name : `${name} [${scopes[name]}]`);
2665
2843
  if (flags.verbose) {
2666
- const flow = config.flows[name] || {};
2844
+ const flow = flows[name] || {};
2667
2845
  for (const [alias, agent] of Object.entries(flow.agents || {})) {
2668
2846
  console.log(` ${alias} -> ${flowAgentKind(agent)}`);
2669
2847
  }
@@ -2680,8 +2858,8 @@ async function cli() {
2680
2858
  console.error("usage: pty-mgr flow show <name>");
2681
2859
  process.exit(1);
2682
2860
  }
2683
- const config = loadFlowConfig(configPath);
2684
- const flow = config.flows?.[name];
2861
+ const { flows } = resolveMergedConfig({ cwd: process.cwd(), explicitPath: flags.config });
2862
+ const flow = flows[name];
2685
2863
  if (!flow) {
2686
2864
  console.error(`flow not found: ${name}`);
2687
2865
  process.exit(1);
@@ -2716,19 +2894,20 @@ async function cli() {
2716
2894
  console.error("flow run requires --task <text>");
2717
2895
  process.exit(1);
2718
2896
  }
2897
+ const runCwd = runFlags.cwd || process.cwd();
2898
+ const merged = resolveMergedConfig({ cwd: runCwd, explicitPath: runFlags.config || flags.config });
2719
2899
  try {
2720
2900
  const result = await runFlowWorkflow({
2721
2901
  workflow,
2722
2902
  task,
2723
2903
  goal: runFlags.goal,
2724
- config: runFlags.config || configPath,
2725
- cwd: runFlags.cwd || process.cwd(),
2904
+ cwd: runCwd,
2726
2905
  maxCycles: runFlags["max-cycles"],
2727
2906
  watchInterval: runFlags["watch-interval"],
2728
2907
  settleMs: runFlags["settle-ms"],
2729
2908
  timeoutMs: runFlags["timeout-ms"],
2730
2909
  intervalMs: runFlags["interval-ms"],
2731
- });
2910
+ }, { config: merged });
2732
2911
  console.log(JSON.stringify(result, null, 2));
2733
2912
  } catch (err) {
2734
2913
  console.error(err.message);
@@ -3031,6 +3210,7 @@ async function cli() {
3031
3210
  console.log(`pty-manager daemon (${st.name})`);
3032
3211
  console.log(` pid: ${st.pid}`);
3033
3212
  console.log(` socket: ${st.socket}`);
3213
+ console.log(` cwd: ${st.cwd}`);
3034
3214
  console.log(` uptime: ${st.uptime}`);
3035
3215
  console.log(` sessions: ${st.sessions.alive} alive, ${st.sessions.dead} dead, ${st.sessions.total} total`);
3036
3216
  console.log(` screen: ${st.config.cols}x${st.config.rows}`);
@@ -3119,7 +3299,10 @@ const isMain = _isBunCompiled || (_basename1 && _pat.test(_basename1)) || (_base
3119
3299
 
3120
3300
  if (isMain) {
3121
3301
  cli().catch((err) => {
3122
- console.error(err);
3302
+ // message only -- a raw error object prints a stack trace for what are
3303
+ // usually plain user-facing failures (session not found, daemon down)
3304
+ console.error("error:", err.message);
3305
+ if (process.env.PTY_MGR_DEBUG) console.error(err.stack);
3123
3306
  process.exit(1);
3124
3307
  });
3125
3308
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentiko/pty-mgr",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "PTY session manager with terminal emulation for programmatic session control.",
5
5
  "type": "module",
6
6
  "main": "lib/pty-manager.mjs",
@@ -26,10 +26,10 @@
26
26
  "@xterm/headless": "^6.0.0"
27
27
  },
28
28
  "optionalDependencies": {
29
- "@mentiko/pty-mgr-linux-x64": "1.3.1",
30
- "@mentiko/pty-mgr-linux-arm64": "1.3.1",
31
- "@mentiko/pty-mgr-darwin-x64": "1.3.1",
32
- "@mentiko/pty-mgr-darwin-arm64": "1.3.1"
29
+ "@mentiko/pty-mgr-linux-x64": "1.4.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"
33
33
  },
34
34
  "engines": {
35
35
  "bun": ">=1.0"