@mentiko/pty-mgr 1.2.4 → 1.3.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.
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +220 -1
- package/lib/pty-manager.mjs +1090 -70
- package/package.json +7 -5
- package/pty-mgr.config.json +187 -0
package/lib/pty-manager.mjs
CHANGED
|
@@ -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 {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.1";
|
|
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
|
-
|
|
587
|
+
let settled = false;
|
|
588
|
+
function cleanup() {
|
|
589
|
+
clearTimeout(timeout);
|
|
590
|
+
clearInterval(poll);
|
|
579
591
|
session.events.off("data", onData);
|
|
580
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
1140
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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\
|
|
1557
|
-
await
|
|
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\
|
|
1568
|
-
await
|
|
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.
|
|
1589
|
-
|
|
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.
|
|
1594
|
-
|
|
1595
|
-
|
|
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("
|
|
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.
|
|
1605
|
-
mgr.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
-
|
|
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
|
|