@mentiko/pty-mgr 1.2.3 → 1.3.0

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.
@@ -28,11 +28,20 @@
28
28
  */
29
29
 
30
30
  import { EventEmitter } from "node:events";
31
+ import { createHash } from "node:crypto";
31
32
  import { dirname, join } from "node:path";
32
- import { createWriteStream, mkdirSync, existsSync, readSync } from "node:fs";
33
-
34
- // replaced by --define at build time
35
- export const VERSION = "1.2.3";
33
+ import {
34
+ createWriteStream,
35
+ mkdirSync,
36
+ existsSync,
37
+ readSync,
38
+ readFileSync,
39
+ readdirSync,
40
+ statSync,
41
+ } from "node:fs";
42
+
43
+ // kept in sync by scripts/version-sync.cjs (rewrites this literal on release)
44
+ export const VERSION = "1.3.0";
36
45
  import xterm from "@xterm/headless";
37
46
  import { SerializeAddon } from "@xterm/addon-serialize";
38
47
 
@@ -528,8 +537,8 @@ export class PtyManager {
528
537
  }
529
538
 
530
539
  /** kill session process (session stays in registry for inspection) */
531
- kill(name) {
532
- this.get(name).kill();
540
+ kill(name, signal = "SIGTERM") {
541
+ this.get(name).kill(signal);
533
542
  }
534
543
 
535
544
  /** rename a session */
@@ -575,30 +584,51 @@ export class PtyManager {
575
584
  const re = typeof pattern === "string" ? new RegExp(pattern) : pattern;
576
585
 
577
586
  return new Promise((resolve, reject) => {
578
- const timeout = setTimeout(() => {
587
+ let settled = false;
588
+ function cleanup() {
589
+ clearTimeout(timeout);
590
+ clearInterval(poll);
579
591
  session.events.off("data", onData);
580
- reject(new Error(`timeout waiting for: ${pattern}`));
592
+ }
593
+
594
+ function settle(fn, value) {
595
+ if (settled) return;
596
+ settled = true;
597
+ cleanup();
598
+ fn(value);
599
+ }
600
+
601
+ function checkCapture() {
602
+ for (const line of session.capture().split("\n")) {
603
+ if (re.test(line)) return line;
604
+ }
605
+ return null;
606
+ }
607
+
608
+ const timeout = setTimeout(() => {
609
+ settle(reject, new Error(`timeout waiting for: ${pattern}`));
581
610
  }, timeoutMs);
611
+ const poll = setInterval(() => {
612
+ const line = checkCapture();
613
+ if (line) settle(resolve, line);
614
+ }, 50);
582
615
 
583
616
  // check current screen
584
- for (const line of session.capture().split("\n")) {
585
- if (re.test(line)) {
586
- clearTimeout(timeout);
587
- resolve(line);
588
- return;
589
- }
617
+ const current = checkCapture();
618
+ if (current) {
619
+ settle(resolve, current);
620
+ return;
590
621
  }
591
622
 
592
623
  // poll on new data (re-read screen each time)
593
- function onData() {
594
- for (const line of session.capture().split("\n")) {
595
- if (re.test(line)) {
596
- clearTimeout(timeout);
597
- session.events.off("data", onData);
598
- resolve(line);
599
- return;
600
- }
624
+ function onData(chunk = "") {
625
+ if (re.test(chunk)) {
626
+ settle(resolve, chunk);
627
+ return;
601
628
  }
629
+
630
+ const line = checkCapture();
631
+ if (line) settle(resolve, line);
602
632
  }
603
633
  session.events.on("data", onData);
604
634
  });
@@ -639,13 +669,34 @@ import { createServer, createConnection } from "node:net";
639
669
  import { unlinkSync, chmodSync } from "node:fs";
640
670
  import { tmpdir, homedir } from "node:os";
641
671
 
642
- // daemon name from @name, --daemon flag, or PTY_DAEMON env var
643
- function getDaemonName() {
644
- const at = process.argv.find((a) => a.startsWith("@"));
645
- if (at) return at.slice(1);
646
- const idx = process.argv.indexOf("--daemon");
647
- if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1];
648
- return process.env.PTY_DAEMON || "default";
672
+ // Split the daemon selector off the front of the CLI tokens.
673
+ //
674
+ // The selector is `@name` or `--daemon <name>`. It is only recognized as a
675
+ // LEADING token, or as the argument that immediately follows a leading
676
+ // `daemon`/`d` command (the documented `p daemon @myproject` form). It is NOT
677
+ // recognized anywhere else, so later `@...` tokens are preserved as data --
678
+ // e.g. `p send agent "@everyone deploy now"` keeps the message intact.
679
+ export function splitDaemonArgs(tokens = []) {
680
+ // pull a selector off the front of an array: returns [name|null, rest]
681
+ const takeSelector = (arr) => {
682
+ if (arr[0] && arr[0].startsWith("@")) return [arr[0].slice(1), arr.slice(1)];
683
+ if (arr[0] === "--daemon" && arr[1]) return [arr[1], arr.slice(2)];
684
+ return [null, arr];
685
+ };
686
+
687
+ let daemon = process.env.PTY_DAEMON || "default";
688
+ let [sel, rest] = takeSelector(tokens);
689
+ if (sel !== null) {
690
+ daemon = sel;
691
+ } else if (rest[0] === "daemon" || rest[0] === "d") {
692
+ // `daemon @name` / `daemon --daemon name`: selector trails the command
693
+ const [sel2, after] = takeSelector(rest.slice(1));
694
+ if (sel2 !== null) {
695
+ daemon = sel2;
696
+ rest = [rest[0], ...after];
697
+ }
698
+ }
699
+ return { daemon, args: rest };
649
700
  }
650
701
 
651
702
  function socketPath(name) {
@@ -655,7 +706,7 @@ function socketPath(name) {
655
706
  return join(dir, `${name}.sock`);
656
707
  }
657
708
 
658
- const DAEMON_NAME = getDaemonName();
709
+ const { daemon: DAEMON_NAME, args: CLI_ARGS } = splitDaemonArgs(process.argv.slice(2));
659
710
  const SOCKET_PATH = socketPath(DAEMON_NAME);
660
711
 
661
712
  /**
@@ -1020,6 +1071,13 @@ export function buildSafeEnv(extra) {
1020
1071
  return safeEnv;
1021
1072
  }
1022
1073
 
1074
+ // POSIX single-quote escaping: wrap in '...' and replace each ' with '\''.
1075
+ // Makes a token literal to the shell -- no expansion, no command substitution,
1076
+ // no word splitting. Used to build the `zsh -lic` command line for `wrap`.
1077
+ export function shellQuote(arg) {
1078
+ return `'${String(arg).replace(/'/g, "'\\''")}'`;
1079
+ }
1080
+
1023
1081
  async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1024
1082
  const { cmd, name, args } = req;
1025
1083
 
@@ -1130,14 +1188,19 @@ async function handleCommand(mgr, req, daemonStartedAt, config, tgState = {}) {
1130
1188
  shellCmd = userShell;
1131
1189
  shellArgs = ["-l"];
1132
1190
  } else {
1133
- // wrap <cmd> [args]: run through login interactive shell
1134
- const escaped = [cmdToRun, ...cmdArgs].map(a => a.includes(" ") ? `"${a}"` : a).join(" ");
1191
+ // wrap <cmd> [args]: run through login interactive shell.
1192
+ // single-quote every token so shell metacharacters in args
1193
+ // (;, |, &, $(), backticks, globs, spaces) are passed literally.
1194
+ const escaped = [cmdToRun, ...cmdArgs].map(shellQuote).join(" ");
1135
1195
  shellCmd = userShell;
1136
1196
  shellArgs = ["-lic", escaped];
1137
1197
  }
1138
1198
 
1139
- // pass full env for wrap (user expects their shell env, not a sandbox)
1140
- const wrapEnv = { ...process.env, ...(args?.env || {}) };
1199
+ // wrap inherits the daemon's real shell env (user expects their env, not
1200
+ // a sandbox), but any client-supplied env overlay is filtered to the
1201
+ // whitelist so a socket client can't inject arbitrary vars (LD_PRELOAD,
1202
+ // DYLD_INSERT_LIBRARIES, ...) into the login shell.
1203
+ const wrapEnv = { ...process.env, ...buildSafeEnv(args?.env) };
1141
1204
 
1142
1205
  mgr.spawn(nextName, shellCmd, shellArgs, { cwd: clientCwd, env: wrapEnv, cols, rows });
1143
1206
  const res = { ok: true, name: nextName, pid: mgr.pid(nextName) };
@@ -1482,11 +1545,13 @@ usage:
1482
1545
  p spawn <name> [cmd] [args...] create session
1483
1546
  p wrap [cmd] [args...] spawn with auto-incrementing cwd name
1484
1547
  p attach <name> interactive mode (ctrl-] detach)
1548
+ p view <name1> <name2> [interval] read-only split-pane live viewer
1485
1549
  p send <name> <text> send text + enter
1486
1550
  p send <name> --raw <text> send text as-is (no enter)
1487
1551
  p capture <name> [lines] capture screen output
1488
1552
  p capture all [lines] capture from all sessions
1489
1553
  p capture <glob*> [lines] capture matching sessions
1554
+ p watch <name> [interval] compare two bottom-100 captures
1490
1555
  p list list all sessions
1491
1556
  p alive <name> check if alive
1492
1557
  p info <name> session details
@@ -1501,6 +1566,9 @@ usage:
1501
1566
  p stop stop current daemon
1502
1567
  p stop all stop all daemons
1503
1568
  p setup wrap CLI tools (claude, etc.)
1569
+ p flow list [--verbose] [--config file] list configured agent workflows
1570
+ p flow show <name> [--config file] show one configured agent workflow
1571
+ p flow run <name> --task <text> run a configured agent workflow
1504
1572
  p demo run self-test (no daemon needed)
1505
1573
  p tg <message> send telegram notification
1506
1574
  p tg <message> --reply send message and wait for reply (blocking)
@@ -1508,7 +1576,7 @@ usage:
1508
1576
 
1509
1577
  shortcuts:
1510
1578
  n|new = spawn w|wrap = wrap s = send c|cap = capture
1511
- st = status a = attach k = kill
1579
+ st = status a = attach v = view k = kill
1512
1580
  l|ls = list i = info r|rm = remove
1513
1581
  mv|ren = rename
1514
1582
  d = daemon cfg = config x = stop
@@ -1543,18 +1611,64 @@ function sleep(ms) {
1543
1611
  return new Promise((r) => setTimeout(r, ms));
1544
1612
  }
1545
1613
 
1614
+ function clipPad(value, width) {
1615
+ const text = String(value || "").replace(/\r/g, "");
1616
+ return text.slice(0, width).padEnd(width, " ");
1617
+ }
1618
+
1619
+ export function composeSideBySideCaptureRows({
1620
+ leftName,
1621
+ rightName,
1622
+ leftCapture = "",
1623
+ rightCapture = "",
1624
+ leftWidth,
1625
+ rightWidth,
1626
+ height,
1627
+ }) {
1628
+ const rows = [];
1629
+ const visibleHeight = Math.max(0, height || 0);
1630
+ const leftLines = String(leftCapture || "").replace(/\r/g, "").split("\n");
1631
+ const rightLines = String(rightCapture || "").replace(/\r/g, "").split("\n");
1632
+
1633
+ if (visibleHeight === 0) return rows;
1634
+
1635
+ rows.push(`${clipPad(leftName, leftWidth)}│${clipPad(rightName, rightWidth)}`);
1636
+
1637
+ for (let i = 1; i < visibleHeight; i++) {
1638
+ rows.push(
1639
+ `${clipPad(leftLines[i - 1] || "", leftWidth)}│${clipPad(rightLines[i - 1] || "", rightWidth)}`
1640
+ );
1641
+ }
1642
+
1643
+ return rows;
1644
+ }
1645
+
1546
1646
  async function runDemo() {
1547
1647
  const mgr = new PtyManager();
1548
1648
 
1549
1649
  console.log("--- pty-manager demo (xterm-headless) ---\n");
1550
1650
 
1551
- console.log("1. spawn 'test-shell' (zsh in pty + xterm emulation)");
1552
- mgr.spawn("test-shell", "zsh", [], { cols: 120, rows: 30 });
1553
- await sleep(1000);
1651
+ const demoShell = `
1652
+ print -r -- DEMO_READY
1653
+ while IFS= read -r line; do
1654
+ print -r -- "> $line"
1655
+ if [ "$line" = exit ]; then
1656
+ exit 0
1657
+ fi
1658
+ eval "$line"
1659
+ done
1660
+ `;
1661
+
1662
+ console.log("1. spawn 'test-shell' (scripted zsh pty + xterm emulation)");
1663
+ mgr.spawn("test-shell", "zsh", ["-f", "-c", demoShell], {
1664
+ cols: 120,
1665
+ rows: 30,
1666
+ });
1667
+ await mgr.waitFor("test-shell", /DEMO_READY/, 5000);
1554
1668
 
1555
1669
  console.log("2. sendKeys: echo hello-from-pty");
1556
- mgr.sendKeys("test-shell", "echo hello-from-pty\n");
1557
- await sleep(600);
1670
+ mgr.sendKeys("test-shell", "echo hello-from-pty\r");
1671
+ await mgr.waitFor("test-shell", /^hello-from-pty$/, 5000);
1558
1672
 
1559
1673
  console.log("3. capture (last 5 lines):");
1560
1674
  console.log(" ---");
@@ -1564,8 +1678,8 @@ async function runDemo() {
1564
1678
  console.log(" ---\n");
1565
1679
 
1566
1680
  console.log("4. sendKeys: ls | head -3");
1567
- mgr.sendKeys("test-shell", "ls | head -3\n");
1568
- await sleep(800);
1681
+ mgr.sendKeys("test-shell", "ls | head -3\r");
1682
+ await mgr.waitFor("test-shell", /^CHANGELOG.md$|^LICENSE$/, 5000);
1569
1683
 
1570
1684
  console.log("5. capture (last 8 lines):");
1571
1685
  console.log(" ---");
@@ -1585,28 +1699,45 @@ async function runDemo() {
1585
1699
  }
1586
1700
 
1587
1701
  console.log("\n8. waitFor: echo MARKER_42");
1588
- mgr.sendKeys("test-shell", "echo MARKER_42\n");
1589
- const match = await mgr.waitFor("test-shell", /MARKER_42/, 5000);
1702
+ mgr.spawn("marker-shell", "zsh", ["-fc", "sleep 0.2; echo MARKER_42"], {
1703
+ cols: 120,
1704
+ rows: 10,
1705
+ });
1706
+ const marker = mgr.waitFor("marker-shell", /MARKER_42/, 5000);
1707
+ const match = await marker;
1590
1708
  console.log(" matched:", match.trim());
1709
+ await mgr.waitForExit("marker-shell", 5000).catch(() => {});
1591
1710
 
1592
1711
  console.log("\n9. tty check:");
1593
- mgr.sendKeys(
1594
- "test-shell",
1595
- 'python3 -c "import sys; print(\'isatty:\', sys.stdout.isatty())"\n'
1596
- );
1712
+ mgr.spawn("tty-check", "python3", ["-c", "import sys; print('isatty:', sys.stdout.isatty())"], {
1713
+ cols: 120,
1714
+ rows: 10,
1715
+ });
1597
1716
  await sleep(800);
1598
1717
  const ttyLine = mgr
1599
- .capture("test-shell", 5)
1718
+ .capture("tty-check", 5)
1600
1719
  .split("\n")
1601
1720
  .find((l) => l.includes("isatty:"));
1602
1721
  console.log(" " + (ttyLine || "(not found)").trim());
1722
+ await mgr.waitForExit("tty-check", 5000).catch(() => {});
1603
1723
 
1604
- console.log("\n10. kill");
1605
- mgr.kill("test-shell");
1606
- await mgr.waitForExit("test-shell", 5000).catch(() => {});
1724
+ console.log("\n10. exit scripted shell");
1725
+ mgr.sendKeys("test-shell", "exit\r");
1726
+ await mgr.waitForExit("test-shell", 5000).catch(() => {
1727
+ mgr.kill("test-shell", "SIGKILL");
1728
+ });
1607
1729
  console.log(" alive:", mgr.isAlive("test-shell"));
1608
1730
 
1609
- console.log("\n11. post-mortem (last 3 lines):");
1731
+ console.log("\n11. kill disposable process");
1732
+ mgr.spawn("kill-test", "zsh", ["-fc", "sleep 30"], { cols: 120, rows: 10 });
1733
+ await sleep(200);
1734
+ mgr.kill("kill-test");
1735
+ await mgr.waitForExit("kill-test", 5000).catch(() => {
1736
+ mgr.kill("kill-test", "SIGKILL");
1737
+ });
1738
+ console.log(" alive:", mgr.isAlive("kill-test"));
1739
+
1740
+ console.log("\n12. post-mortem (last 3 lines):");
1610
1741
  for (const l of mgr.capture("test-shell", 3).split("\n")) {
1611
1742
  console.log(" | " + l);
1612
1743
  }
@@ -1637,16 +1768,756 @@ ${cmd}() {
1637
1768
  local _p
1638
1769
  _p=$(command -v p 2>/dev/null || command -v pty-mgr 2>/dev/null)
1639
1770
  $_p status >/dev/null 2>&1 || $_p daemon
1640
- local _name
1641
- _name=$($_p wrap ${cmd} "$@" 2>&1 | awk '{print $1}')
1771
+ local _wrap_out _wrap_status _name
1772
+ _wrap_out=$($_p wrap command ${cmd} "$@" 2>&1)
1773
+ _wrap_status=$?
1774
+ if [ $_wrap_status -ne 0 ]; then
1775
+ echo "pty-mgr: wrap failed for ${cmd}" >&2
1776
+ case "$_wrap_out" in
1777
+ *"unknown command: wrap"*) echo "pty-mgr: daemon does not support wrap; restart it after saving active sessions" >&2 ;;
1778
+ esac
1779
+ [ -n "$_wrap_out" ] && printf '%s\\n' "$_wrap_out" >&2
1780
+ return $_wrap_status
1781
+ fi
1782
+ _name=$(printf '%s\\n' "$_wrap_out" | awk '$2 ~ /^pid=[0-9]+$/ { print $1; exit }')
1642
1783
  if [ -z "$_name" ]; then
1643
- echo "pty-mgr: wrap failed" >&2
1784
+ echo "pty-mgr: wrap failed for ${cmd}: unexpected output" >&2
1785
+ [ -n "$_wrap_out" ] && printf '%s\\n' "$_wrap_out" >&2
1644
1786
  return 1
1645
1787
  fi
1646
1788
  $_p attach "$_name"
1647
1789
  }`;
1648
1790
  }
1649
1791
 
1792
+ function parseWatchIntervalMs(value = "4s") {
1793
+ const raw = String(value || "4s").trim().toLowerCase();
1794
+ const match = raw.match(/^(\d+(?:\.\d+)?)(ms|s)?$/);
1795
+ if (!match) throw new Error("watch interval must be milliseconds or seconds, e.g. 4000ms or 4s");
1796
+ const amount = Number(match[1]);
1797
+ const unit = match[2] || "ms";
1798
+ const ms = unit === "s" ? amount * 1000 : amount;
1799
+ if (!Number.isFinite(ms) || ms < 0) {
1800
+ throw new Error("watch interval must be a non-negative duration");
1801
+ }
1802
+ return Math.round(ms);
1803
+ }
1804
+
1805
+ async function verifyViewSession(name) {
1806
+ const res = await sendCommand({ cmd: "capture", name, args: { lines: 1, screen: true } });
1807
+ if (!res.ok) {
1808
+ throw new Error(`session not found: ${name}`);
1809
+ }
1810
+ }
1811
+
1812
+ async function viewSessions(leftName, rightName, intervalMs = 500) {
1813
+ if (!leftName || !rightName) {
1814
+ throw new Error("usage: pty-mgr view <name1> <name2> [interval]");
1815
+ }
1816
+
1817
+ const width = process.stdout.columns || 80;
1818
+ if (width < 21) {
1819
+ throw new Error("terminal too narrow for split view; need at least 21 columns");
1820
+ }
1821
+
1822
+ await verifyViewSession(leftName);
1823
+ await verifyViewSession(rightName);
1824
+
1825
+ let stopped = false;
1826
+ let drawing = false;
1827
+ let timer = null;
1828
+ const stdinWasRaw = Boolean(process.stdin.isRaw);
1829
+
1830
+ const cleanup = () => {
1831
+ if (stopped) return;
1832
+ stopped = true;
1833
+ if (timer) clearTimeout(timer);
1834
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
1835
+ if (process.stdin.isTTY) {
1836
+ try { process.stdin.setRawMode(stdinWasRaw); } catch {}
1837
+ process.stdin.pause();
1838
+ process.stdin.off("data", onData);
1839
+ }
1840
+ process.off("SIGINT", onSignal);
1841
+ process.off("SIGTERM", onSignal);
1842
+ process.off("exit", cleanup);
1843
+ };
1844
+
1845
+ const onData = (chunk) => {
1846
+ const text = chunk.toString("utf8");
1847
+ if (text === "q" || text === "\x03") {
1848
+ cleanup();
1849
+ process.exit(0);
1850
+ }
1851
+ };
1852
+
1853
+ const onSignal = () => {
1854
+ cleanup();
1855
+ process.exit(0);
1856
+ };
1857
+
1858
+ const draw = async () => {
1859
+ if (stopped || drawing) return;
1860
+ drawing = true;
1861
+ try {
1862
+ const cols = process.stdout.columns || 80;
1863
+ const rows = process.stdout.rows || 24;
1864
+ if (cols < 21) {
1865
+ process.stdout.write("\x1b[H\x1b[2Jterminal too narrow for split view; need at least 21 columns");
1866
+ } else {
1867
+ const leftWidth = Math.floor((cols - 1) / 2);
1868
+ const rightWidth = cols - 1 - leftWidth;
1869
+ const captureLines = Math.max(1, rows - 1);
1870
+ const [left, right] = await Promise.all([
1871
+ sendCommand({ cmd: "capture", name: leftName, args: { lines: captureLines, screen: true } }),
1872
+ sendCommand({ cmd: "capture", name: rightName, args: { lines: captureLines, screen: true } }),
1873
+ ]);
1874
+ if (!left.ok) throw new Error(`session not found: ${leftName}`);
1875
+ if (!right.ok) throw new Error(`session not found: ${rightName}`);
1876
+ const viewRows = composeSideBySideCaptureRows({
1877
+ leftName,
1878
+ rightName,
1879
+ leftCapture: left.output || "",
1880
+ rightCapture: right.output || "",
1881
+ leftWidth,
1882
+ rightWidth,
1883
+ height: rows,
1884
+ });
1885
+ // Position each row absolutely at column 1. A bare "\n" in raw/alt-screen
1886
+ // mode is a line feed without carriage return, so full-width rows cascade
1887
+ // and scroll instead of stacking. Explicit cursor moves avoid all wrap.
1888
+ let frame = "\x1b[H\x1b[2J";
1889
+ for (let r = 0; r < viewRows.length; r++) {
1890
+ frame += `\x1b[${r + 1};1H${viewRows[r]}`;
1891
+ }
1892
+ process.stdout.write(frame);
1893
+ }
1894
+ } catch (err) {
1895
+ cleanup();
1896
+ console.error(err.message);
1897
+ process.exit(1);
1898
+ } finally {
1899
+ drawing = false;
1900
+ if (!stopped) timer = setTimeout(draw, intervalMs);
1901
+ }
1902
+ };
1903
+
1904
+ process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
1905
+ if (process.stdin.isTTY) {
1906
+ process.stdin.setRawMode(true);
1907
+ process.stdin.resume();
1908
+ process.stdin.on("data", onData);
1909
+ }
1910
+ process.on("SIGINT", onSignal);
1911
+ process.on("SIGTERM", onSignal);
1912
+ process.on("exit", cleanup);
1913
+
1914
+ await draw();
1915
+ }
1916
+
1917
+ function parseFlagArgs(tokens, startIndex = 0) {
1918
+ const flags = {};
1919
+ const rest = [];
1920
+ for (let i = startIndex; i < tokens.length; i++) {
1921
+ const token = tokens[i];
1922
+ if (token.startsWith("--")) {
1923
+ const key = token.slice(2);
1924
+ const next = tokens[i + 1];
1925
+ if (next == null || next.startsWith("--")) {
1926
+ flags[key] = true;
1927
+ } else {
1928
+ flags[key] = next;
1929
+ i++;
1930
+ }
1931
+ } else {
1932
+ rest.push(token);
1933
+ }
1934
+ }
1935
+ return { flags, rest };
1936
+ }
1937
+
1938
+ function hashText(value) {
1939
+ return createHash("sha256").update(value || "").digest("hex");
1940
+ }
1941
+
1942
+ function capturePayloadText(res) {
1943
+ if (res.results) {
1944
+ return Object.entries(res.results)
1945
+ .sort(([a], [b]) => a.localeCompare(b))
1946
+ .map(([name, output]) => `--- ${name} ---\n${output || ""}`)
1947
+ .join("\n");
1948
+ }
1949
+ return res.output || "";
1950
+ }
1951
+
1952
+ function expandHome(value) {
1953
+ if (!value) return value;
1954
+ if (value === "~") return homedir();
1955
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
1956
+ return value;
1957
+ }
1958
+
1959
+ export function projectKeyForCwd(cwd) {
1960
+ return cwd.replace(/\//g, "-");
1961
+ }
1962
+
1963
+ export function loadFlowConfig(configPath = "pty-mgr.config.json") {
1964
+ if (configPath && existsSync(configPath)) {
1965
+ return JSON.parse(readFileSync(configPath, "utf8"));
1966
+ }
1967
+ return { adapters: {}, flows: {} };
1968
+ }
1969
+
1970
+ function walkJsonlFiles(root, files = []) {
1971
+ root = expandHome(root);
1972
+ if (!root || !existsSync(root)) return files;
1973
+
1974
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
1975
+ const path = join(root, entry.name);
1976
+ if (entry.isDirectory()) {
1977
+ walkJsonlFiles(path, files);
1978
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
1979
+ files.push(path);
1980
+ }
1981
+ }
1982
+ return files;
1983
+ }
1984
+
1985
+ function timestampMs(value) {
1986
+ const ms = Date.parse(value || "");
1987
+ return Number.isFinite(ms) ? ms : null;
1988
+ }
1989
+
1990
+ function getPath(obj, path) {
1991
+ if (!path) return obj;
1992
+ return String(path).split(".").reduce((value, part) => {
1993
+ if (value == null) return undefined;
1994
+ return value[part];
1995
+ }, obj);
1996
+ }
1997
+
1998
+ function matchesWhere(obj, where = {}) {
1999
+ return Object.entries(where).every(([path, expected]) => getPath(obj, path) === expected);
2000
+ }
2001
+
2002
+ function normalizeMessageText(value) {
2003
+ return String(value || "").replace(/\r\n/g, "\n").trim();
2004
+ }
2005
+
2006
+ function renderTemplate(value, context) {
2007
+ return String(value)
2008
+ .replaceAll("${home}", homedir())
2009
+ .replaceAll("${cwd}", context.cwd)
2010
+ .replaceAll("${projectKey}", projectKeyForCwd(context.cwd));
2011
+ }
2012
+
2013
+ function adapterForKind(kind, config) {
2014
+ const adapter = config.adapters?.[kind];
2015
+ if (!adapter) throw new Error(`missing adapter config for agent kind: ${kind}`);
2016
+ return adapter;
2017
+ }
2018
+
2019
+ function transcriptStartedAtMs(file, adapter) {
2020
+ const lines = readFileSync(file, "utf8").split("\n");
2021
+ const timestampPaths = adapter.sessionTimestampPaths || ["payload.timestamp", "timestamp"];
2022
+
2023
+ for (const raw of lines.slice(0, 25)) {
2024
+ const line = raw.trim();
2025
+ if (!line) continue;
2026
+
2027
+ let obj;
2028
+ try {
2029
+ obj = JSON.parse(line);
2030
+ } catch {
2031
+ continue;
2032
+ }
2033
+
2034
+ for (const path of timestampPaths) {
2035
+ const ms = timestampMs(getPath(obj, path));
2036
+ if (ms !== null) return ms;
2037
+ }
2038
+ }
2039
+
2040
+ return null;
2041
+ }
2042
+
2043
+ export function transcriptRootsForKind(kind, config, cwd = process.cwd()) {
2044
+ const adapter = adapterForKind(kind, config);
2045
+ return (adapter.roots || [])
2046
+ .map((root) => expandHome(renderTemplate(root, { cwd })))
2047
+ .filter(Boolean)
2048
+ .filter((root, index, roots) => existsSync(root) || index === roots.length - 1);
2049
+ }
2050
+
2051
+ export function findNewestTranscript({ kind, cwd = process.cwd(), sinceMs = 0, config }) {
2052
+ const adapter = adapterForKind(kind, config);
2053
+ const roots = transcriptRootsForKind(kind, config, cwd);
2054
+ const candidates = roots
2055
+ .flatMap((root) => walkJsonlFiles(root))
2056
+ .map((path) => {
2057
+ const mtimeMs = statSync(path).mtimeMs;
2058
+ const startedMs = transcriptStartedAtMs(path, adapter);
2059
+ const matchMs = startedMs ?? mtimeMs;
2060
+ return { path, mtimeMs, startedMs, matchMs };
2061
+ })
2062
+ .filter((file) => file.matchMs >= sinceMs)
2063
+ .sort((a, b) => {
2064
+ const aHasStart = a.startedMs !== null;
2065
+ const bHasStart = b.startedMs !== null;
2066
+ if (aHasStart && bHasStart) return b.startedMs - a.startedMs;
2067
+ if (aHasStart !== bHasStart) return aHasStart ? -1 : 1;
2068
+ return b.mtimeMs - a.mtimeMs;
2069
+ });
2070
+
2071
+ return candidates[0]?.path || null;
2072
+ }
2073
+
2074
+ function textFromSelector(obj, selector) {
2075
+ if (selector.array) {
2076
+ const value = getPath(obj, selector.array);
2077
+ if (!Array.isArray(value)) return "";
2078
+ return value
2079
+ .filter((part) => part && typeof part === "object")
2080
+ .filter((part) => matchesWhere(part, selector.where || {}))
2081
+ .map((part) => getPath(part, selector.path || "text"))
2082
+ .filter((text) => typeof text === "string" && text.length > 0)
2083
+ .join("\n")
2084
+ .trim();
2085
+ }
2086
+
2087
+ const value = getPath(obj, selector.path);
2088
+ return typeof value === "string" ? value.trim() : "";
2089
+ }
2090
+
2091
+ function recordTextFromObject(obj, spec = {}, stripPatterns = []) {
2092
+ if (!matchesWhere(obj, spec.where || {})) return "";
2093
+ if (spec.complete && !matchesWhere(obj, spec.complete)) return "";
2094
+
2095
+ let text = (spec.text || [])
2096
+ .map((selector) => textFromSelector(obj, selector))
2097
+ .filter(Boolean)
2098
+ .join("\n")
2099
+ .trim();
2100
+
2101
+ for (const pattern of stripPatterns) {
2102
+ text = text.replace(new RegExp(pattern, "m"), "").trim();
2103
+ }
2104
+
2105
+ return normalizeMessageText(text);
2106
+ }
2107
+
2108
+ function assistantTextFromObject(obj, adapter) {
2109
+ return recordTextFromObject(obj, adapter.assistant || {}, adapter.stripPatterns || []);
2110
+ }
2111
+
2112
+ function userTextFromObject(obj, adapter) {
2113
+ return recordTextFromObject(obj, adapter.user || {});
2114
+ }
2115
+
2116
+ function messageKey(obj, lineNumber) {
2117
+ return [
2118
+ obj.timestamp,
2119
+ obj.uuid,
2120
+ obj.payload?.id,
2121
+ obj.payload?.call_id,
2122
+ lineNumber,
2123
+ ].filter(Boolean).join(":");
2124
+ }
2125
+
2126
+ function lineFromMessageKey(key) {
2127
+ const line = Number(String(key || "").split(":").at(-1));
2128
+ return Number.isInteger(line) && line > 0 ? line : 0;
2129
+ }
2130
+
2131
+ export function extractLastAssistantMessage(file, kind, afterKey = "", config = loadFlowConfig()) {
2132
+ const adapter = adapterForKind(kind, config);
2133
+ const lines = readFileSync(file, "utf8").split("\n");
2134
+ const afterLine = lineFromMessageKey(afterKey);
2135
+
2136
+ for (let i = lines.length - 1; i >= 0; i--) {
2137
+ const line = lines[i].trim();
2138
+ if (!line) continue;
2139
+
2140
+ let obj;
2141
+ try {
2142
+ obj = JSON.parse(line);
2143
+ } catch {
2144
+ continue;
2145
+ }
2146
+
2147
+ const text = assistantTextFromObject(obj, adapter);
2148
+ if (!text) continue;
2149
+
2150
+ const key = messageKey(obj, i + 1);
2151
+ if (afterLine && i + 1 <= afterLine) continue;
2152
+ if (afterKey && key === afterKey) return null;
2153
+ return { key, text, file };
2154
+ }
2155
+
2156
+ return null;
2157
+ }
2158
+
2159
+ function findSentMessageInTranscript(file, kind, sentText, config = loadFlowConfig()) {
2160
+ const adapter = adapterForKind(kind, config);
2161
+ if (!adapter.user) {
2162
+ throw new Error(`agent kind ${kind} must define user parser to bind flow transcripts`);
2163
+ }
2164
+
2165
+ const wanted = normalizeMessageText(sentText);
2166
+ if (!wanted) return null;
2167
+
2168
+ const lines = readFileSync(file, "utf8").split("\n");
2169
+ for (let i = lines.length - 1; i >= 0; i--) {
2170
+ const line = lines[i].trim();
2171
+ if (!line) continue;
2172
+
2173
+ let obj;
2174
+ try {
2175
+ obj = JSON.parse(line);
2176
+ } catch {
2177
+ continue;
2178
+ }
2179
+
2180
+ const text = userTextFromObject(obj, adapter);
2181
+ if (!text || text !== wanted) continue;
2182
+
2183
+ return { key: messageKey(obj, i + 1), text, file };
2184
+ }
2185
+
2186
+ return null;
2187
+ }
2188
+
2189
+ export function findTranscriptForSentMessage({
2190
+ kind,
2191
+ cwd = process.cwd(),
2192
+ text,
2193
+ config,
2194
+ file,
2195
+ }) {
2196
+ if (file) return findSentMessageInTranscript(file, kind, text, config);
2197
+
2198
+ const candidates = transcriptRootsForKind(kind, config, cwd)
2199
+ .flatMap((root) => walkJsonlFiles(root))
2200
+ .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
2201
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
2202
+
2203
+ for (const candidate of candidates) {
2204
+ const match = findSentMessageInTranscript(candidate.path, kind, text, config);
2205
+ if (match) return match;
2206
+ }
2207
+
2208
+ return null;
2209
+ }
2210
+
2211
+ function renderSteeringTemplate(template, context) {
2212
+ return String(template || "")
2213
+ .replaceAll("{task}", context.task || "")
2214
+ .replaceAll("{goal}", context.goal || context.task || "")
2215
+ .replaceAll("{lastMessage}", context.lastMessage || "")
2216
+ .replaceAll("{cycle}", String(context.cycle ?? ""))
2217
+ .replaceAll("{from}", context.from || "")
2218
+ .replaceAll("{to}", context.to || "");
2219
+ }
2220
+
2221
+ export function buildFlowTurnMessage(turn, context) {
2222
+ if (turn.template) return renderSteeringTemplate(turn.template, context).trim();
2223
+ const text = (context.lastMessage || "").trim();
2224
+ const steering = turn.append ? renderSteeringTemplate(turn.append, context).trim() : "";
2225
+ return [text, steering].filter(Boolean).join("\n\n");
2226
+ }
2227
+
2228
+ function flowAgentKind(agent) {
2229
+ return agent.kind;
2230
+ }
2231
+
2232
+ function workflowForName(config, workflowName) {
2233
+ const workflow = config.flows?.[workflowName];
2234
+ if (!workflow) throw new Error(`missing flow config: ${workflowName}`);
2235
+ if (!workflow.agents || typeof workflow.agents !== "object") {
2236
+ throw new Error(`flow ${workflowName} must define agents`);
2237
+ }
2238
+ if (!workflow.start?.to) throw new Error(`flow ${workflowName} must define start.to`);
2239
+ if (!Array.isArray(workflow.turns) || workflow.turns.length === 0) {
2240
+ throw new Error(`flow ${workflowName} must define at least one turn`);
2241
+ }
2242
+ return workflow;
2243
+ }
2244
+
2245
+ async function ensureDaemonRunning() {
2246
+ try {
2247
+ const res = await sendCommand({ cmd: "status" });
2248
+ if (res.ok) return;
2249
+ } catch {}
2250
+ throw new Error("daemon not running; start it with: p daemon");
2251
+ }
2252
+
2253
+ async function launchFlowAgent(alias, agent, config, options, deps = {}) {
2254
+ const kind = flowAgentKind(agent);
2255
+ if (!kind) throw new Error(`flow agent ${alias} must define kind`);
2256
+ const adapter = adapterForKind(kind, config);
2257
+ const cwd = agent.cwd || options.cwd || process.cwd();
2258
+ const startedAtMs = Date.now();
2259
+
2260
+ if (agent.session) {
2261
+ const transcriptFile = agent.transcript
2262
+ ? expandHome(renderTemplate(agent.transcript, { cwd }))
2263
+ : null;
2264
+ return { alias, session: agent.session, kind, cwd, startedAtMs, transcriptFile };
2265
+ }
2266
+
2267
+ const command = agent.command || adapter.command || kind;
2268
+ const args = agent.args || adapter.defaultArgs || [];
2269
+ if (deps.launchAgent) return deps.launchAgent(alias, { command, args, kind, cwd, startedAtMs });
2270
+
2271
+ const res = await sendCommand({
2272
+ cmd: "wrap",
2273
+ args: {
2274
+ cmd: command,
2275
+ args,
2276
+ cwd,
2277
+ base: agent.base || alias,
2278
+ },
2279
+ });
2280
+ if (!res.ok) throw new Error(res.error || `failed to launch agent ${alias}`);
2281
+ const transcriptFile = agent.transcript
2282
+ ? expandHome(renderTemplate(agent.transcript, { cwd }))
2283
+ : null;
2284
+ return { alias, session: res.name, kind, cwd, startedAtMs, transcriptFile };
2285
+ }
2286
+
2287
+ async function sendFlowMessage(session, text, deps = {}) {
2288
+ if (deps.sendMessage) return deps.sendMessage(session, text);
2289
+ const res = await sendCommand({ cmd: "send", name: session, args: { text, enter: true } });
2290
+ if (!res.ok) throw new Error(res.error || `failed to send to ${session}`);
2291
+ return res;
2292
+ }
2293
+
2294
+ async function waitForFlowSessionReady(session, { tries = 12, intervalMs = 1500 } = {}) {
2295
+ // A freshly spawned agent CLI (codex, claude) repaints a splash/tips screen
2296
+ // while booting; sending the first prompt during that repaint drops the
2297
+ // keystrokes. Poll until the rendered screen is non-empty and unchanged
2298
+ // between two samples (idle at its prompt). Best-effort: returns after the cap.
2299
+ let prev = null;
2300
+ for (let i = 0; i < tries; i++) {
2301
+ const cap = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2302
+ const text = cap.ok ? capturePayloadText(cap) : "";
2303
+ if (prev !== null && text.trim().length > 0 && text === prev) {
2304
+ await sleep(400);
2305
+ return true;
2306
+ }
2307
+ prev = text;
2308
+ await sleep(intervalMs);
2309
+ }
2310
+ return false;
2311
+ }
2312
+
2313
+ async function sendFlowMessageConfirmed(meta, text, config, deps = {}) {
2314
+ // Send a prompt and confirm the agent actually accepted it. A freshly booted
2315
+ // TUI can leave typed input un-submitted (the Enter races startup), which
2316
+ // strands the flow waiting for a turn that never runs. If the sent message
2317
+ // doesn't appear in the agent's transcript, nudge submit with a bare Enter.
2318
+ // The nudge is always just Enter (never re-typed) so a spurious retry can't
2319
+ // duplicate input. Returns whether acceptance was confirmed.
2320
+ await sendFlowMessage(meta.session, text, deps);
2321
+ meta.lastSentText = text;
2322
+ meta.lastUserKey = "";
2323
+ if (deps.launchAgent) return true; // dep-injected (test) mode: no real transcript
2324
+ const nudges = 2;
2325
+ for (let attempt = 0; attempt <= nudges; attempt++) {
2326
+ const polls = attempt === 0 ? 5 : 4;
2327
+ for (let i = 0; i < polls; i++) {
2328
+ await sleep(3000);
2329
+ const bound = findTranscriptForSentMessage({
2330
+ kind: meta.kind,
2331
+ cwd: meta.cwd,
2332
+ text,
2333
+ config,
2334
+ file: meta.transcriptFile,
2335
+ });
2336
+ if (bound) {
2337
+ meta.transcriptFile = bound.file;
2338
+ meta.lastUserKey = bound.key;
2339
+ return true;
2340
+ }
2341
+ }
2342
+ if (attempt < nudges) await sendFlowMessage(meta.session, "", deps);
2343
+ }
2344
+ return false;
2345
+ }
2346
+
2347
+ async function watchFlowSession(session, intervalMs, deps = {}) {
2348
+ if (deps.watchSession) return deps.watchSession(session, intervalMs);
2349
+ const first = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2350
+ if (!first.ok) throw new Error(first.error || `failed to capture ${session}`);
2351
+ const firstHash = hashText(capturePayloadText(first));
2352
+ await sleep(intervalMs);
2353
+ const second = await sendCommand({ cmd: "capture", name: session, args: { lines: 100 } });
2354
+ if (!second.ok) throw new Error(second.error || `failed to capture ${session}`);
2355
+ const secondHash = hashText(capturePayloadText(second));
2356
+ return firstHash === secondHash ? "done" : "working";
2357
+ }
2358
+
2359
+ async function waitForFlowMessage({
2360
+ meta,
2361
+ config,
2362
+ afterKey,
2363
+ intervalMs,
2364
+ timeoutMs,
2365
+ watchIntervalMs,
2366
+ settleMs,
2367
+ deps,
2368
+ }) {
2369
+ const started = Date.now();
2370
+ while (true) {
2371
+ const status = await watchFlowSession(meta.session, watchIntervalMs, deps);
2372
+ if (status === "done") {
2373
+ if (settleMs > 0) {
2374
+ await sleep(settleMs);
2375
+ const settled = await watchFlowSession(meta.session, watchIntervalMs, deps);
2376
+ if (settled !== "done") {
2377
+ if (settled !== "working") {
2378
+ throw new Error(`unexpected watch status for ${meta.session}: ${settled}`);
2379
+ }
2380
+ await sleep(intervalMs);
2381
+ continue;
2382
+ }
2383
+ }
2384
+
2385
+ const bound = findTranscriptForSentMessage({
2386
+ kind: meta.kind,
2387
+ cwd: meta.cwd,
2388
+ text: meta.lastSentText,
2389
+ config,
2390
+ file: meta.transcriptFile,
2391
+ });
2392
+ if (bound) {
2393
+ meta.transcriptFile = bound.file;
2394
+ meta.lastUserKey = bound.key;
2395
+ const msg = extractLastAssistantMessage(
2396
+ bound.file,
2397
+ meta.kind,
2398
+ meta.lastUserKey || afterKey || "",
2399
+ config
2400
+ );
2401
+ if (msg?.text) return msg;
2402
+ }
2403
+ } else if (status !== "working") {
2404
+ throw new Error(`unexpected watch status for ${meta.session}: ${status}`);
2405
+ }
2406
+
2407
+ if (timeoutMs && Date.now() - started > timeoutMs) return null;
2408
+ await sleep(intervalMs);
2409
+ }
2410
+ }
2411
+
2412
+ export async function runFlowWorkflow(options, deps = {}) {
2413
+ const config = deps.config || loadFlowConfig(options.config);
2414
+ const workflow = workflowForName(config, options.workflow);
2415
+ const watchIntervalMs = parseWatchIntervalMs(
2416
+ options.watchInterval || workflow.watchInterval || "10s"
2417
+ );
2418
+ const intervalMs = Number(options.intervalMs ?? workflow.intervalMs ?? 1000);
2419
+ const settleMs = Number(options.settleMs ?? workflow.settleMs ?? 1500);
2420
+ const timeoutMs = Number(options.timeoutMs ?? workflow.timeoutMs ?? 0);
2421
+ const maxCycles = Number(options.maxCycles ?? workflow.maxCycles ?? 1);
2422
+ const task = options.task || "";
2423
+ const goal = options.goal || workflow.goal || task;
2424
+ const agents = {};
2425
+ const events = [];
2426
+
2427
+ if (!deps.launchAgent) await ensureDaemonRunning();
2428
+
2429
+ for (const [alias, agent] of Object.entries(workflow.agents)) {
2430
+ agents[alias] = await launchFlowAgent(alias, agent, config, options, deps);
2431
+ events.push({ type: "agent", alias, session: agents[alias].session, kind: agents[alias].kind });
2432
+ }
2433
+
2434
+ const startAgent = agents[workflow.start.to];
2435
+ if (!startAgent) throw new Error(`flow start target not found: ${workflow.start.to}`);
2436
+ const startText = renderSteeringTemplate(workflow.start.template || "{task}", {
2437
+ task,
2438
+ goal,
2439
+ cycle: 0,
2440
+ to: workflow.start.to,
2441
+ });
2442
+ if (!deps.launchAgent) await waitForFlowSessionReady(startAgent.session);
2443
+ const startAccepted = await sendFlowMessageConfirmed(startAgent, startText, config, deps);
2444
+ events.push({ type: "send", from: "user", to: workflow.start.to, text: startText, accepted: startAccepted });
2445
+ if (!startAccepted) {
2446
+ return {
2447
+ completed: false,
2448
+ waitingFor: workflow.start.to,
2449
+ reason: "start agent never accepted the initial task (input typed but not submitted)",
2450
+ cycle: 0,
2451
+ agents,
2452
+ events,
2453
+ };
2454
+ }
2455
+
2456
+ const lastKeys = {};
2457
+ for (let cycle = 0; cycle < maxCycles; cycle++) {
2458
+ for (const turn of workflow.turns) {
2459
+ const fromMeta = agents[turn.from];
2460
+ const toMeta = agents[turn.to];
2461
+ if (!fromMeta) throw new Error(`flow turn source not found: ${turn.from}`);
2462
+ if (!toMeta) throw new Error(`flow turn target not found: ${turn.to}`);
2463
+
2464
+ const msg = await waitForFlowMessage({
2465
+ meta: fromMeta,
2466
+ config,
2467
+ afterKey: lastKeys[turn.from] || "",
2468
+ intervalMs,
2469
+ timeoutMs,
2470
+ watchIntervalMs,
2471
+ settleMs,
2472
+ deps,
2473
+ });
2474
+ if (!msg) {
2475
+ return { completed: false, waitingFor: turn.from, cycle, agents, events };
2476
+ }
2477
+
2478
+ lastKeys[turn.from] = msg.key;
2479
+ const text = buildFlowTurnMessage(turn, {
2480
+ task,
2481
+ goal,
2482
+ cycle: cycle + 1,
2483
+ from: turn.from,
2484
+ to: turn.to,
2485
+ lastMessage: msg.text,
2486
+ });
2487
+ const accepted = await sendFlowMessageConfirmed(toMeta, text, config, deps);
2488
+ events.push({ type: "turn", cycle: cycle + 1, from: turn.from, to: turn.to, key: msg.key, text, accepted });
2489
+ }
2490
+ }
2491
+
2492
+ return { completed: true, workflow: options.workflow, cycles: maxCycles, agents, events };
2493
+ }
2494
+
2495
+ function escapeRegex(value) {
2496
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2497
+ }
2498
+
2499
+ function replaceManagedWrapper(rcContent, cmd, fn) {
2500
+ const marker = `# pty-mgr: managed ${cmd} sessions`;
2501
+ const pattern = new RegExp(
2502
+ `\\n?${escapeRegex(marker)}\\n${escapeRegex(cmd)}\\(\\) \\{[\\s\\S]*?\\n\\}\\n?`,
2503
+ "m"
2504
+ );
2505
+ const match = rcContent.match(pattern);
2506
+
2507
+ if (!match) return { content: rcContent, changed: false, found: false };
2508
+ if (match[0].trim() === fn.trim()) {
2509
+ return { content: rcContent, changed: false, found: true };
2510
+ }
2511
+
2512
+ const prefix = match[0].startsWith("\n") ? "\n" : "";
2513
+ const suffix = match[0].endsWith("\n") ? "\n" : "";
2514
+ return {
2515
+ content: rcContent.replace(pattern, `${prefix}${fn.trim()}\n${suffix}`),
2516
+ changed: true,
2517
+ found: true,
2518
+ };
2519
+ }
2520
+
1650
2521
  function detectRcFile() {
1651
2522
  const shell = process.env.SHELL || "";
1652
2523
  if (shell.endsWith("/zsh")) return join(homedir(), ".zshrc");
@@ -1660,7 +2531,7 @@ function detectRcFile() {
1660
2531
  }
1661
2532
 
1662
2533
  async function runSetup() {
1663
- const { appendFileSync, readFileSync } = await import("node:fs");
2534
+ const { appendFileSync, readFileSync, writeFileSync } = await import("node:fs");
1664
2535
 
1665
2536
  console.log("pty-mgr setup");
1666
2537
  console.log("wrap CLI tools in managed PTY sessions.\n");
@@ -1699,21 +2570,33 @@ async function runSetup() {
1699
2570
 
1700
2571
  // write to rc file
1701
2572
  let added = [];
2573
+ let updated = [];
1702
2574
  for (const cmd of wrapped) {
1703
2575
  const marker = `# pty-mgr: managed ${cmd} sessions`;
2576
+ const fn = wrapperFunction(cmd);
1704
2577
  if (rcContent.includes(marker)) {
1705
- console.log(`'${cmd}' already in ${rcFile}, skipping`);
2578
+ const replaced = replaceManagedWrapper(rcContent, cmd, fn);
2579
+ if (replaced.changed) {
2580
+ rcContent = replaced.content;
2581
+ writeFileSync(rcFile, rcContent);
2582
+ updated.push(cmd);
2583
+ } else {
2584
+ console.log(`'${cmd}' already in ${rcFile}, skipping`);
2585
+ }
1706
2586
  continue;
1707
2587
  }
1708
- const fn = wrapperFunction(cmd);
1709
2588
  appendFileSync(rcFile, "\n" + fn + "\n");
2589
+ rcContent += "\n" + fn + "\n";
1710
2590
  added.push(cmd);
1711
2591
  }
1712
2592
 
1713
- closeAsk();
1714
-
1715
2593
  if (added.length > 0) {
1716
2594
  console.log(`\nadded to ${rcFile}: ${added.join(", ")}`);
2595
+ }
2596
+ if (updated.length > 0) {
2597
+ console.log(`\nupdated in ${rcFile}: ${updated.join(", ")}`);
2598
+ }
2599
+ if (added.length > 0 || updated.length > 0) {
1717
2600
  console.log("\nrestart your shell or run:");
1718
2601
  console.log(` source ${rcFile}`);
1719
2602
  } else {
@@ -1722,14 +2605,9 @@ async function runSetup() {
1722
2605
  }
1723
2606
 
1724
2607
  async function cli() {
1725
- // strip @name and --daemon <name> from argv (already consumed by getDaemonName)
1726
- const cleaned = process.argv.slice(2).filter((a, i, arr) => {
1727
- if (a.startsWith("@")) return false;
1728
- if (a === "--daemon") return false;
1729
- if (i > 0 && arr[i - 1] === "--daemon") return false;
1730
- return true;
1731
- });
1732
- const [rawCommand, ...args] = cleaned;
2608
+ // daemon selector already consumed by splitDaemonArgs at module load;
2609
+ // CLI_ARGS is the remaining command + args, with data tokens preserved.
2610
+ const [rawCommand, ...args] = CLI_ARGS;
1733
2611
 
1734
2612
  // command aliases - short forms
1735
2613
  const ALIASES = {
@@ -1739,6 +2617,7 @@ async function cli() {
1739
2617
  c: "capture", cap: "capture",
1740
2618
  st: "status",
1741
2619
  a: "attach",
2620
+ v: "view",
1742
2621
  k: "kill",
1743
2622
  l: "list", ls: "list",
1744
2623
  i: "info",
@@ -1763,7 +2642,7 @@ async function cli() {
1763
2642
 
1764
2643
  if (command === "demo") {
1765
2644
  await runDemo();
1766
- return;
2645
+ process.exit(0);
1767
2646
  }
1768
2647
 
1769
2648
  if (command === "setup") {
@@ -1771,6 +2650,97 @@ async function cli() {
1771
2650
  return;
1772
2651
  }
1773
2652
 
2653
+ if (command === "flow") {
2654
+ const subcommand = args[0];
2655
+ const { flags } = parseFlagArgs(args, 1);
2656
+ const configPath = flags.config || "pty-mgr.config.json";
2657
+ if (subcommand === "list") {
2658
+ const config = loadFlowConfig(configPath);
2659
+ const names = Object.keys(config.flows || {});
2660
+ if (names.length === 0) {
2661
+ console.log("no flows configured");
2662
+ } else {
2663
+ for (const name of names) {
2664
+ console.log(name);
2665
+ if (flags.verbose) {
2666
+ const flow = config.flows[name] || {};
2667
+ for (const [alias, agent] of Object.entries(flow.agents || {})) {
2668
+ console.log(` ${alias} -> ${flowAgentKind(agent)}`);
2669
+ }
2670
+ console.log(` start -> ${flow.start?.to || ""}, maxCycles -> ${flow.maxCycles ?? 1}`);
2671
+ }
2672
+ }
2673
+ }
2674
+ return;
2675
+ }
2676
+
2677
+ if (subcommand === "show") {
2678
+ const name = args[1];
2679
+ if (!name) {
2680
+ console.error("usage: pty-mgr flow show <name>");
2681
+ process.exit(1);
2682
+ }
2683
+ const config = loadFlowConfig(configPath);
2684
+ const flow = config.flows?.[name];
2685
+ if (!flow) {
2686
+ console.error(`flow not found: ${name}`);
2687
+ process.exit(1);
2688
+ }
2689
+
2690
+ console.log(name);
2691
+ console.log("agents:");
2692
+ for (const [alias, agent] of Object.entries(flow.agents || {})) {
2693
+ console.log(` ${alias} -> ${flowAgentKind(agent)}`);
2694
+ }
2695
+ console.log(`start -> ${flow.start?.to || ""}`);
2696
+ console.log("turns:");
2697
+ for (const turn of flow.turns || []) {
2698
+ console.log(` ${turn.from || ""} -> ${turn.to || ""}`);
2699
+ console.log(` append: ${turn.append || ""}`);
2700
+ }
2701
+ console.log(`maxCycles -> ${flow.maxCycles ?? 1}`);
2702
+ console.log(`watchInterval -> ${flow.watchInterval ?? "10s"}`);
2703
+ console.log(`settleMs -> ${flow.settleMs ?? 1500}`);
2704
+ return;
2705
+ }
2706
+
2707
+ if (subcommand === "run") {
2708
+ const workflow = args[1];
2709
+ if (!workflow) {
2710
+ console.error("usage: pty-mgr flow run <name> --task <text>");
2711
+ process.exit(1);
2712
+ }
2713
+ const { flags: runFlags } = parseFlagArgs(args, 2);
2714
+ const task = runFlags.task || "";
2715
+ if (!task) {
2716
+ console.error("flow run requires --task <text>");
2717
+ process.exit(1);
2718
+ }
2719
+ try {
2720
+ const result = await runFlowWorkflow({
2721
+ workflow,
2722
+ task,
2723
+ goal: runFlags.goal,
2724
+ config: runFlags.config || configPath,
2725
+ cwd: runFlags.cwd || process.cwd(),
2726
+ maxCycles: runFlags["max-cycles"],
2727
+ watchInterval: runFlags["watch-interval"],
2728
+ settleMs: runFlags["settle-ms"],
2729
+ timeoutMs: runFlags["timeout-ms"],
2730
+ intervalMs: runFlags["interval-ms"],
2731
+ });
2732
+ console.log(JSON.stringify(result, null, 2));
2733
+ } catch (err) {
2734
+ console.error(err.message);
2735
+ process.exit(1);
2736
+ }
2737
+ return;
2738
+ }
2739
+
2740
+ console.error("usage: pty-mgr flow list|show|run");
2741
+ process.exit(1);
2742
+ }
2743
+
1774
2744
  if (command === "tg") {
1775
2745
  const flags = { reply: false, timeout: 60 };
1776
2746
  const parts = [];
@@ -1901,6 +2871,56 @@ async function cli() {
1901
2871
  return;
1902
2872
  }
1903
2873
 
2874
+ if (command === "view") {
2875
+ const [leftName, rightName, intervalArg] = args;
2876
+ let intervalMs;
2877
+ try {
2878
+ intervalMs = parseWatchIntervalMs(intervalArg || "500ms");
2879
+ await viewSessions(leftName, rightName, intervalMs);
2880
+ } catch (err) {
2881
+ console.error(err.message);
2882
+ process.exit(1);
2883
+ }
2884
+ return;
2885
+ }
2886
+
2887
+ if (command === "watch") {
2888
+ const name = args[0];
2889
+ if (!name) {
2890
+ console.error("usage: pty-mgr watch <name> [interval]");
2891
+ process.exit(1);
2892
+ }
2893
+
2894
+ let intervalMs;
2895
+ try {
2896
+ intervalMs = parseWatchIntervalMs(args[1] || "4s");
2897
+ } catch (err) {
2898
+ console.error(err.message);
2899
+ process.exit(1);
2900
+ }
2901
+
2902
+ try {
2903
+ const first = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
2904
+ if (!first.ok) {
2905
+ console.error("error:", first.error);
2906
+ process.exit(1);
2907
+ }
2908
+ const firstHash = hashText(capturePayloadText(first));
2909
+ await sleep(intervalMs);
2910
+ const second = await sendCommand({ cmd: "capture", name, args: { lines: 100 } });
2911
+ if (!second.ok) {
2912
+ console.error("error:", second.error);
2913
+ process.exit(1);
2914
+ }
2915
+ const secondHash = hashText(capturePayloadText(second));
2916
+ console.log(firstHash === secondHash ? "done" : "working");
2917
+ process.exit(0);
2918
+ } catch (err) {
2919
+ console.error(err.message);
2920
+ process.exit(1);
2921
+ }
2922
+ }
2923
+
1904
2924
  // all other commands go through the daemon
1905
2925
  const name = args[0];
1906
2926