@dev-anywhere/proxy 0.0.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/LICENSE +21 -0
- package/README.md +113 -0
- package/assets/fonts/sarasa-fixed-sc/00c59ca448ea49756112128d6a43b127.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/011decfe3e67ab099af86e86a15e4f12.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0133a2c4604dd809764dc749af72dc79.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/02928e8a3c2bf5ae6677bdf552664108.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/02eb3185bd0fd343bb2ae5a13260e805.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/04248a8ea5cd1204f875bc7661f1096c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/059276218fbb9297f1668b1e37611796.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/05c6ae1e7580a1eff4be47341a155474.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0681e7debe2dd44962f142b5c454676e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/06ec078ae766d56e900c2837556eaa21.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0765562d298aed694539cfcc4e26537c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/090128a865f81baab82dfa2776ef9d39.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/098ff8830b5d779d8656e7ca93253c12.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/09b4eba4363eb15092534724123a78f2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0a32f2f6ed1a99ffa5e833e6891d1f82.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0c1c080e1ac07d6a49dca531de9725a4.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0d022c7c63996b8e5dc70b7fd7b0689d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/0e7c7848d16895e68f79a55fd7b8c29d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1021bfe59ce4f905808dd5c3ea3fecc4.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/103c3dd7a5ee0d3be6cab3d04bd40c96.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/104a138cdd04cdbf3f786d2abeeed70a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/105c39ea073d59b2e2f5e16ca68b2d71.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1113c82b3ef6588e32cf20d7bd723355.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1284d569e10290971bf3200bf4ddf94f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/13401a392cffb9f2f7254e91bf44a106.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1364ce313965ebf440ff2f0311cd4c83.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1641d3bae07fd8d369349f78dd6c9c40.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/187edd679db5112bd2699bf1adad4a32.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1918eaee7987c39db5198d637623a538.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1aa2711726a364e3ea6fb7ca8a95d950.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1ab01fa7bd43e3242f3c9d5b6f73dc90.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1ae20f7adc1aa93e95c1842ce3b0c329.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1c16e221d67a71a270f700e31e406230.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1c40fcc59ed13b4b861844a8feb8205b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1d0e76f87701ab6daecb38b5aff5e35c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1d18a292fa9adaae7261de62ab3d5b83.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/1f99a7c91c71e6303282aa66c99c6670.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/20e0dab161488105b7fa7852470503b1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/210b0efe54084a98b88028a6f3f35b0d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2116632ed1a3bdf3bf0106e1703c0781.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/22b3a91f654e334e2e7fbe3780f67764.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/24c5d226f02d96f03a7d050b0fdfd1d1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/254d3a725f55d7111abbafa05858f2d6.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/26506a3ce2dbbfbfde40d11d65230272.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/268f0d094899c94260e840ac55320e20.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/277fc44cecacaf16cefce4681495bdce.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/27b773ac20ca5fa1bdd09d2109dfd5dc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2803a14ad17e6753704504009dad3333.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2a0d78f9037bc2b5846bb4bcb42bfedc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2ac0bf1c4f9ad6e69bc01259f6e17058.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2b01deb59e13f60e307d370b674b7b3b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2b74162a48979b6282e4893199f1346d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2d11bafb03690fc87494426325d90414.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/2dbf9030d7fa41019c78d5a717726261.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/30ce0c911f54ecd538247018d5289bb6.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/31bc5b0f3eb1e0f0845f37ae39f1a43e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/31cdb9be17f46537a2958ba142c9765f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/31d182ea6704bc50689becec8f417674.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/31f6abd51bf73062085ad82253fc302d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/32d1abf03444175b162eadfef3a8e937.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/3397f69dc93262cf856a7c836fb33cb1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/349035f271962ecacc5fa5cd56930c44.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/3525d0f8fb4e24d09024d35992e9095d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/393876ac692b10f5ab7190590b062745.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/3bf37030a116e7377dcaf47356f08ef1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/3c953d2e03b93436ee5c522ce3a27e1a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/3fe795fd56b36a734a31613b1fed5441.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/401e69f55dc74b2e6fb774dcbba811da.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/404518f4b01a2fe38462bd04bfd76d35.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/404fe750213a60cbd07016ab43aacb71.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/41491053defe7688d8d53bfc313c6974.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/420121d03c1887873aa0eaa37a534e0c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/42dffba83a406a85b208fba09e465c4d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/44b74f10fbe44658d1acb6627b3a8b94.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/450be7e82a27dca52c8c1c582fcf182d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/454ab2db1835c113f7b99f366a627247.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/45ec91cf9684c378adb99130acd6477e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4610b2980f41ab106bfe74b6951b34bb.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/46cad9f32908fa67609b51b74bc5693b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4a51964240b7e2c93d276dfef3794b5b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4b800716e356efb3e4b429085bcdb61a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4d8dad1e6c57ca3629d21230981cbb0e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4e625675fc1cb257e07a9ff2e331a9fe.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/4f2207b0342d6e2dd4290614e53cf0c5.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/50b0854263c0cf842979ce447020051a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/552fe6d8e7144695a8fb38b8760295df.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/56175fa000590397d0ec4074c1f1e9f2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/570f8c343327a818e4f0b0c4962ba4a1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/579fbe9abeb15d0e96ee4770e6719719.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/57c37c54a9699166f03c0e6f871e6735.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/58e7c2324d8d292d58534d9f236f1552.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/59584acacc4c1852a6814f7e389ea191.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/598705446ef817283f04a709e974d473.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/59e93050018a5206bebf980e0f52fa78.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/5c129ed60734fd511937f3e72a07f936.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/5c40c780ef301baacb66efb9750e258b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/5d1a982cb896e55ee96ada379c4a1d0c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/5eba8579b88524026f6f6ec30cb81baa.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/601ad6234d43753b4e3efe8486b315c1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/603b9e361dc9dd05250221356255a176.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6133adaaf70a9467188b7e9e34039480.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6158d9e8e90c6a553485c49e8adc991c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/63c707f20b4af887788bfe53c6eee3ec.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/63c8475e9aa8acc0033fde9f792b8acf.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/64466c4274848cdee185275fb52660df.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/652d2da7b3c1dc0a70efdbb85e820055.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6653b549c4d40a370724f6f32223e8a7.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6734957b7e0bdd9f9e66cf24be7dc058.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/689a7d17cf5f82d7f148d660a21df759.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6a864ec605e3b28cfdebf1d8fab2451e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6b4101e34138d999fd3ba9d2684ddebf.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6b70494a68af284b2f21967109bf0311.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6dde2f1140ce0a20159b7c1625bc3b45.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/6faa59cc7dff0a7751347b5baa8a5015.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/70364e9456886f13e2d5c9674ffb08f4.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/736f91321c58d7a9316ea9cddf4d83a7.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7458762c6210651559b6019d5508ae2c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7460d9b012420e17a4bbfd2cb6796468.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/75a0c446c8bf8e4051d7cd65ed17e114.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/77ee59e7be1d66b59e87880e9c283491.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/782cda0bc3f24b5553f93c3d5b6fe581.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/78e84df88175c20e7f2562e4cdaad257.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/79e46c399f5e7383f853281b499269ee.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7acff1baf69f6628c6372be0554191f1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7c5323aa737b291adacceecedac3e56a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7c9c9097a200464fd8ccec8eafc80178.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7f77666d943dd01e947a2e35ffed945f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/7f7e1463e29e6528378ed51589c4ebc0.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/80d4285084348d043a775221eb1c93b1.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/81258e5d1abdeafe44be69ad32f74c19.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/83a8da8e1de25f9eb39fe9d980e04a5d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/83be0efd62f30539368d37cf7529f95c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/84266b5a67707c745ce2c7101c559735.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/84905ac6e9d149bcacc34e2a71b82a67.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/852ae9183bf83dc2a2414290d3aa0236.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/85f5aa76f5f1e09b6852af36ec781960.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/860a1c6d20d4e631bd8e80735f5f3581.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8afa5d1ab930027046ad09ec6349ab31.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8b81739358772947619c9a58575c863a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8b9212f06a935902e9a4d8ad435c6a6e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8cc210fa91949bb44ea2bf607991b7bf.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8f0b853b5728d58ac137870b3fd7ab02.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8f0cdbad1758d5f68a48effbca6f74f8.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/8f5261cd877891595879479ee2440ae4.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/910a758ea12189f5a6a1e025fca33c5a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/911993a058e817f1a231fbac27b3781c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/91daedcfd8d0745222bf82c1fd310a33.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/943d6560828548692eeb4a533aa89494.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/945d0c206221572e76cc03fdd474685a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/946411ab6f4f655273da613188359c06.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/9514ffa1f75fb1ad995392125c963b1b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/971106de1248ba224fbfe50cf5c8a3dc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/9714a586ded7b76747709b1fbe32c344.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/98b75b8dba788b62ef809337088ef030.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/98b7b436090e7874e2affbfd4ad28fd9.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/9a952ea6da219d92841b5503d43c746e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a0eabe8e58be580b4a5b0db87e3d282b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a11e9313ebf3147b1622c47e8cb1bacd.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a2030c8eca8b7b75cf8d6fbbf29940cb.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a2e0e75587d53e7e6c50da180ad76815.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a3e96d6e1a2bdfe27d35140d1cb84600.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a645858417844eaeb35eec60463a8758.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a691a029b0ffe2f516dc0ba2680a7b24.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a8731b06984003e15944af2045bac0ba.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/a8b5a37da8e22efc24e1d51e3dd133f2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/aa4030d4e6a032ebd37ab080a87f0b15.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ab0f237ab351bf258b3579011285bdb2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ac3a84416815f292543ac24c909b8f53.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ac4188ae92c726da6cce789716706455.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ac9e1d7b7d0e738c0965e0c37a171594.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ad0efa49e97c104bb4f7d2a5d15eabef.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ad3ac003a089fb00191c1e00aa56061e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ae481a90ce6dc650ffb8e777ab9fc089.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/aef606698752748d6b411ab230cd1760.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/aefc6f1da14ea5d5daf751fbf71b29a2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/afe37b0740a4f9cc8f6a8aa5515a7826.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b1a36f37842e3cd3981ee51b5d9b3edc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b3791b3fb0de0352fb1265cb02a021fb.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b37e11130ac85f74432248a20b134622.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b39422dfd00810613d15bd6572b05aa3.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b3e514ad857c99211370e0bc572f776f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b4bd13b2c8086a5320644893d557b747.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b6b23d001929bf0450f2ee16e9236f1f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b78cefc4f6a28eb5b5dd551dd873bcbd.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b7e35bfc1e67312ea4f7399d87101681.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b8493cdc783af55e9c50fc16c9b55bc3.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/b8e187dc66c9864eb348ee91951eacdf.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/bbf72353d229f1e5d6ccea65888451fe.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/bc6f1bd38107e5c152f4d568e75b3cf0.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/bc884e50f2cf284e61c6cc8274bb3cc5.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/bd0f4de744997828af2f5be5ec11428f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/bf85faaa5f8efc2e1023f5e9504c0d4e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/c509336eb0f6e35088064f15ddddc844.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/c5dd4097aac455d055badef188473371.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/c66f535c634eadaf34e0fe041672ae4b.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/c693a3ac340ea557c7ee39ddef0451a2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/c8e0baa6e08346d410255ea827a8be27.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ca8a49ee7846219a65d63ee1ec51c194.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/cd52152b8b48869f0fb1175983d9a505.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/cea8a7b71e661b383a7dc2f15cc2f47a.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d2e284352932a735dda55727c0879153.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d2fd9591ebfd0e779de7682d2745dbbb.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d4a14f2651525d4affbbb753b6dbdf07.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d5caf76d2720e294f4d0b1625f01ec9c.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d5d463ae4788148f67a28de9ec4c1dca.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d6e2c1e4c4b88bc14079e9c4a7549497.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d70ea44ff65a30a175801cb104f83448.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d750b314131793e148b4ebf50ffb91af.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d7ae616c789f29be08782214e0288a0d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/d8ae70630c929796cb0364e5202037e4.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/da5bdab3ae018a4c1207bd67ab854bc2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/dab7b758f386eaae246f675ec7e23f0f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/db1cc9bd868a6ff45d7bbe5b28d4b8d7.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/dc34cae5db8221efc393ea494597b1ec.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/dd32cf24e261a9aa261b7e196da33f2d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/dda618cc791e2c20645ca5b7843c50dc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/df9de632322bb3378c647420760aab61.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e00c9c693f510281ead88131d09c18d2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e02d34cae25f04c920a31db2c80d1d8f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e22efb34fb5fe61f5b45504d76d20781.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e25f5e7e1d5b88a59d981b5f6f669283.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e3bb4be954796cb47ac6851ab724ecaf.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e3bdeda10d9131256fad8417fd1da09f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e73e3950ff542ab97969b032c1f7241f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e78879cfa489561d3ec6eef16a4a0a4d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e811136959f0512a25a18acc520970b5.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/e9a7cf7d3d15fb2e50730469b07f2e39.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/eb514af63c1e9b14ccec7dee432b8edc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ed0b4344be5485ee0b0c207f28762fb8.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/edf89add9339773b26c1c2dd2244a179.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ef8de64aca34834c6f110f4adc7515bd.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f0438b1823af0a0de674c239fa03eaba.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f0604027051cb387c2f85940dfff3755.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f065b514c78eb2344c42e496865369a9.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f0d97a6f8e1880316015bc2283d0f156.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f1834c69e3074e71093d17a68b9d4d7d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f29b465128cba69d5e68235945cd3edd.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f330fd41c2fbf0ae89bb98047c08d516.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f383e78159d65c11032dc8b8d9c360f3.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f647c059126fa5bf24355dcbfed09c4f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f6af810265a209ab307886171ded4113.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f74844d2228da8984fe156b5b9c28acc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f7f2840050c367f26cfbc2b81c3a5fad.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f85435b23bb50124e045f50cfe54dd3e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f865cd6ba4d22d8d61fce2a2d63be7dc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/f8a66c1ab7db90820de697f664ea136d.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fa6cf45901b333e63b3eabc045783c69.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fa827d13030f9366a57edbfc22ce4336.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fb6ac0e1252b3d366009d4f4ffeab4fc.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fbb64faabca9f28743b2d0d5dc448e5f.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fd299dbef627ab6738d61d4e4bac56e2.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/fd8cd455b632147c1758bfb9bd0ba42e.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/ff8cf784cc8ef060bf9d2dc7e698b451.woff2 +0 -0
- package/assets/fonts/sarasa-fixed-sc/result.css +20 -0
- package/dist/chunk-2Q3Z3ICU.js +155 -0
- package/dist/chunk-2Q3Z3ICU.js.map +1 -0
- package/dist/chunk-6O6JTF24.js +318 -0
- package/dist/chunk-6O6JTF24.js.map +1 -0
- package/dist/chunk-OO64L35C.js +34 -0
- package/dist/chunk-OO64L35C.js.map +1 -0
- package/dist/chunk-QJ5CQDK7.js +1038 -0
- package/dist/chunk-QJ5CQDK7.js.map +1 -0
- package/dist/chunk-UGFYGF3Y.js +142 -0
- package/dist/chunk-UGFYGF3Y.js.map +1 -0
- package/dist/chunk-ZUWAB67J.js +55 -0
- package/dist/chunk-ZUWAB67J.js.map +1 -0
- package/dist/index.js +279 -0
- package/dist/index.js.map +1 -0
- package/dist/serve.js +4375 -0
- package/dist/serve.js.map +1 -0
- package/dist/session-worker.js +357 -0
- package/dist/session-worker.js.map +1 -0
- package/dist/terminal-TTRA2VJY.js +671 -0
- package/dist/terminal-TTRA2VJY.js.map +1 -0
- package/package.json +67 -0
package/dist/serve.js
ADDED
|
@@ -0,0 +1,4375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ContentBlockDeltaSchema,
|
|
4
|
+
IGNORED_EVENT_TYPES,
|
|
5
|
+
KnownContentBlockSchema,
|
|
6
|
+
SeqCounter,
|
|
7
|
+
StreamJsonEventSchema
|
|
8
|
+
} from "./chunk-2Q3Z3ICU.js";
|
|
9
|
+
import {
|
|
10
|
+
createFSM,
|
|
11
|
+
defineFSM,
|
|
12
|
+
extractOscSequences,
|
|
13
|
+
extractOscSignals,
|
|
14
|
+
serviceLogger,
|
|
15
|
+
shouldReleaseApprovalWait,
|
|
16
|
+
stateAfterApprovalRelease
|
|
17
|
+
} from "./chunk-UGFYGF3Y.js";
|
|
18
|
+
import {
|
|
19
|
+
spawnScript
|
|
20
|
+
} from "./chunk-ZUWAB67J.js";
|
|
21
|
+
import {
|
|
22
|
+
CLAUDE_PROVIDER,
|
|
23
|
+
CODEX_PROVIDER,
|
|
24
|
+
detectAgentCliStatus
|
|
25
|
+
} from "./chunk-6O6JTF24.js";
|
|
26
|
+
import {
|
|
27
|
+
CONFIG_PATH,
|
|
28
|
+
ControlErrorCode,
|
|
29
|
+
DATA_DIR,
|
|
30
|
+
PID_PATH,
|
|
31
|
+
SESSIONS_PATH,
|
|
32
|
+
SOCK_PATH,
|
|
33
|
+
STOPPED_PATH,
|
|
34
|
+
SessionState,
|
|
35
|
+
buildMessage,
|
|
36
|
+
createIpcReader,
|
|
37
|
+
createWorkerReader,
|
|
38
|
+
serializeIpc,
|
|
39
|
+
serializeWorkerMsg,
|
|
40
|
+
sessionPaths,
|
|
41
|
+
tildify
|
|
42
|
+
} from "./chunk-QJ5CQDK7.js";
|
|
43
|
+
|
|
44
|
+
// src/serve.ts
|
|
45
|
+
import { createServer as createServer2 } from "net";
|
|
46
|
+
import { unlinkSync as unlinkSync3, writeFileSync as writeFileSync4, chmodSync, rmSync as rmSync2 } from "fs";
|
|
47
|
+
|
|
48
|
+
// src/serve/session-manager.ts
|
|
49
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync, existsSync } from "fs";
|
|
50
|
+
import { dirname } from "path";
|
|
51
|
+
import { nanoid } from "nanoid";
|
|
52
|
+
var PTY_TRANSITIONS = {
|
|
53
|
+
[SessionState.IDLE]: [
|
|
54
|
+
// claude 开始响应用户输入 → handlePtyData 首字节翻 working
|
|
55
|
+
SessionState.WORKING,
|
|
56
|
+
// provider hook 是语义事件,可能比 PTY 字节观察更早到达;PermissionRequest 可直接进入审批等待。
|
|
57
|
+
SessionState.WAITING_APPROVAL,
|
|
58
|
+
// 终态兜底;现阶段 terminated 走 terminateSession 直接删 map 不经 updateState,本边未被触发
|
|
59
|
+
SessionState.TERMINATED
|
|
60
|
+
],
|
|
61
|
+
[SessionState.WORKING]: [
|
|
62
|
+
// 5s 静默且 currentPtyState === "working" → idle timer 推 turn_complete
|
|
63
|
+
SessionState.IDLE,
|
|
64
|
+
// claude 发 OSC 9 "needs your permission: <tool>" → handlePtyData 推 approval_wait
|
|
65
|
+
SessionState.WAITING_APPROVAL,
|
|
66
|
+
// 终态兜底
|
|
67
|
+
SessionState.TERMINATED
|
|
68
|
+
],
|
|
69
|
+
[SessionState.WAITING_APPROVAL]: [
|
|
70
|
+
// 审批解除后 provider 可能继续工作,也可能直接结束本轮。
|
|
71
|
+
// 真实 Claude 拒绝工具审批后会直接发 turn_complete,因此 WAITING_APPROVAL -> IDLE 是合法边。
|
|
72
|
+
SessionState.WORKING,
|
|
73
|
+
SessionState.IDLE,
|
|
74
|
+
// 终态兜底
|
|
75
|
+
SessionState.TERMINATED
|
|
76
|
+
],
|
|
77
|
+
// PTY 永不进入 ERROR;本行仅为满足 Record<SessionState,_> 枚举完整性保留
|
|
78
|
+
[SessionState.ERROR]: [SessionState.TERMINATED],
|
|
79
|
+
[SessionState.TERMINATED]: []
|
|
80
|
+
};
|
|
81
|
+
var JSON_TRANSITIONS = {
|
|
82
|
+
[SessionState.IDLE]: [
|
|
83
|
+
// 用户在 relay/web 端发消息 → onTurnStart,turn 开始
|
|
84
|
+
SessionState.WORKING,
|
|
85
|
+
// 空闲期观察通道失联(worker socket 死但 pid 仍在等)→ onChannelBroken
|
|
86
|
+
SessionState.ERROR,
|
|
87
|
+
// 终态兜底;同 PTY,当前不经 updateState
|
|
88
|
+
SessionState.TERMINATED
|
|
89
|
+
],
|
|
90
|
+
[SessionState.WORKING]: [
|
|
91
|
+
// stream-json result event → onTurnResult,turn 结束
|
|
92
|
+
SessionState.IDLE,
|
|
93
|
+
// claude 发 control_request → onApprovalRequested,阻塞等审批
|
|
94
|
+
SessionState.WAITING_APPROVAL,
|
|
95
|
+
// turn 进行中通道失联 → onChannelBroken
|
|
96
|
+
SessionState.ERROR,
|
|
97
|
+
// 终态兜底
|
|
98
|
+
SessionState.TERMINATED
|
|
99
|
+
],
|
|
100
|
+
[SessionState.WAITING_APPROVAL]: [
|
|
101
|
+
// 粒度丢失:审批解除后 claude 继续跑,proxy 观察不到中间的 WORKING 信号,
|
|
102
|
+
// 直到 result event 才感知 → onTurnResult 一次性从 WAITING_APPROVAL 跳到 IDLE。
|
|
103
|
+
// 因此不列 WAITING_APPROVAL → WORKING 这条边。
|
|
104
|
+
SessionState.IDLE,
|
|
105
|
+
// 审批死锁:control_response 写 worker stdin 失败 → onChannelBroken。
|
|
106
|
+
// 这是 ERROR 态最明确的落地场景,让 UI 能区分 "正在等用户决定" 和 "审批通道坏了"。
|
|
107
|
+
SessionState.ERROR,
|
|
108
|
+
// 终态兜底
|
|
109
|
+
SessionState.TERMINATED
|
|
110
|
+
],
|
|
111
|
+
[SessionState.ERROR]: [
|
|
112
|
+
// 观察通道坏了之后只能 terminate,不回 IDLE/WORKING——恢复机制未实现
|
|
113
|
+
SessionState.TERMINATED
|
|
114
|
+
],
|
|
115
|
+
[SessionState.TERMINATED]: []
|
|
116
|
+
};
|
|
117
|
+
var ptyFSM = defineFSM(PTY_TRANSITIONS);
|
|
118
|
+
var jsonFSM = defineFSM(JSON_TRANSITIONS);
|
|
119
|
+
function fsmForMode(mode) {
|
|
120
|
+
return mode === "pty" ? ptyFSM : jsonFSM;
|
|
121
|
+
}
|
|
122
|
+
function isProviderId(value) {
|
|
123
|
+
return value === "claude" || value === "codex";
|
|
124
|
+
}
|
|
125
|
+
var SessionManager = class {
|
|
126
|
+
sessions = /* @__PURE__ */ new Map();
|
|
127
|
+
reaperTimer = null;
|
|
128
|
+
persistPath;
|
|
129
|
+
reaperIntervalMs;
|
|
130
|
+
onSessionRemoved;
|
|
131
|
+
constructor(options) {
|
|
132
|
+
this.persistPath = options.persistPath;
|
|
133
|
+
this.reaperIntervalMs = options.reaperIntervalMs ?? 6e4;
|
|
134
|
+
this.onSessionRemoved = options.onSessionRemoved;
|
|
135
|
+
this.load();
|
|
136
|
+
}
|
|
137
|
+
createSession(mode, cwd, pid, name, id, provider = "claude", ptyOwner) {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const info = {
|
|
140
|
+
id: id ?? nanoid(),
|
|
141
|
+
mode,
|
|
142
|
+
provider,
|
|
143
|
+
...mode === "pty" && ptyOwner !== void 0 ? { ptyOwner } : {},
|
|
144
|
+
state: SessionState.IDLE,
|
|
145
|
+
createdAt: now,
|
|
146
|
+
updatedAt: now,
|
|
147
|
+
cwd,
|
|
148
|
+
pid,
|
|
149
|
+
...name !== void 0 ? { name } : {}
|
|
150
|
+
};
|
|
151
|
+
this.sessions.set(info.id, info);
|
|
152
|
+
this.save();
|
|
153
|
+
serviceLogger.info({ sessionId: info.id, mode, provider, ptyOwner, name }, "Session created");
|
|
154
|
+
return info;
|
|
155
|
+
}
|
|
156
|
+
listSessions() {
|
|
157
|
+
return Array.from(this.sessions.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
158
|
+
}
|
|
159
|
+
getSession(id) {
|
|
160
|
+
return this.sessions.get(id);
|
|
161
|
+
}
|
|
162
|
+
updateState(id, newState) {
|
|
163
|
+
const session = this.sessions.get(id);
|
|
164
|
+
if (!session) {
|
|
165
|
+
throw new Error(`Session not found: ${id}`);
|
|
166
|
+
}
|
|
167
|
+
const oldState = session.state;
|
|
168
|
+
if (oldState === newState) return false;
|
|
169
|
+
const fsm = fsmForMode(session.mode);
|
|
170
|
+
if (!fsm.canTransition(oldState, newState)) {
|
|
171
|
+
const level = fsm.isAbsorbing(oldState) ? "debug" : "warn";
|
|
172
|
+
serviceLogger[level](
|
|
173
|
+
{ sessionId: id, from: oldState, to: newState, mode: session.mode },
|
|
174
|
+
level === "debug" ? "State change after absorbing state (residual, likely race)" : "Invalid state transition rejected by FSM"
|
|
175
|
+
);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
session.state = newState;
|
|
179
|
+
session.updatedAt = Date.now();
|
|
180
|
+
this.save();
|
|
181
|
+
serviceLogger.info({ sessionId: id, from: oldState, to: newState }, "Session state changed");
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
terminateSession(id, context) {
|
|
185
|
+
const session = this.sessions.get(id);
|
|
186
|
+
if (!session) {
|
|
187
|
+
return { success: false };
|
|
188
|
+
}
|
|
189
|
+
const pid = session.pid;
|
|
190
|
+
this.sessions.delete(id);
|
|
191
|
+
this.save();
|
|
192
|
+
serviceLogger.info({ sessionId: id, mode: session.mode, pid }, "Session terminated");
|
|
193
|
+
this.onSessionRemoved?.(id, context);
|
|
194
|
+
return { success: true, pid };
|
|
195
|
+
}
|
|
196
|
+
terminateAll() {
|
|
197
|
+
const pids = [];
|
|
198
|
+
const ids = Array.from(this.sessions.keys());
|
|
199
|
+
for (const id of ids) {
|
|
200
|
+
const session = this.sessions.get(id);
|
|
201
|
+
if (session.mode === "json" && session.pid !== void 0) {
|
|
202
|
+
pids.push(session.pid);
|
|
203
|
+
}
|
|
204
|
+
this.sessions.delete(id);
|
|
205
|
+
this.onSessionRemoved?.(id);
|
|
206
|
+
}
|
|
207
|
+
this.save();
|
|
208
|
+
return pids;
|
|
209
|
+
}
|
|
210
|
+
setClaudeSessionId(id, claudeSessionId) {
|
|
211
|
+
const session = this.sessions.get(id);
|
|
212
|
+
if (!session) {
|
|
213
|
+
throw new Error(`Session not found: ${id}`);
|
|
214
|
+
}
|
|
215
|
+
session.claudeSessionId = claudeSessionId;
|
|
216
|
+
this.save();
|
|
217
|
+
}
|
|
218
|
+
setPid(id, pid) {
|
|
219
|
+
const session = this.sessions.get(id);
|
|
220
|
+
if (!session) {
|
|
221
|
+
throw new Error(`Session not found: ${id}`);
|
|
222
|
+
}
|
|
223
|
+
session.pid = pid;
|
|
224
|
+
this.save();
|
|
225
|
+
}
|
|
226
|
+
startReaper(intervalMs = this.reaperIntervalMs) {
|
|
227
|
+
this.stopReaper();
|
|
228
|
+
this.reaperTimer = setInterval(() => this.reap(), intervalMs);
|
|
229
|
+
}
|
|
230
|
+
stopReaper() {
|
|
231
|
+
if (this.reaperTimer) {
|
|
232
|
+
clearInterval(this.reaperTimer);
|
|
233
|
+
this.reaperTimer = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
reap() {
|
|
237
|
+
const toRemove = [];
|
|
238
|
+
for (const session of this.sessions.values()) {
|
|
239
|
+
if (session.mode === "json" && session.pid !== void 0 && session.state !== SessionState.TERMINATED) {
|
|
240
|
+
if (!this.isProcessAlive(session.pid)) {
|
|
241
|
+
toRemove.push({ id: session.id, reason: `JSON worker process ${session.pid} is dead` });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const { id, reason } of toRemove) {
|
|
246
|
+
serviceLogger.warn({ sessionId: id, reason }, "Reaping stale session");
|
|
247
|
+
this.terminateSession(id);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
isProcessAlive(pid) {
|
|
251
|
+
try {
|
|
252
|
+
process.kill(pid, 0);
|
|
253
|
+
return true;
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
save() {
|
|
259
|
+
const dir = dirname(this.persistPath);
|
|
260
|
+
mkdirSync(dir, { recursive: true });
|
|
261
|
+
const persisted = Array.from(this.sessions.values()).map((s) => ({
|
|
262
|
+
id: s.id,
|
|
263
|
+
mode: s.mode,
|
|
264
|
+
provider: s.provider,
|
|
265
|
+
createdAt: s.createdAt,
|
|
266
|
+
updatedAt: s.updatedAt,
|
|
267
|
+
cwd: s.cwd,
|
|
268
|
+
pid: s.pid,
|
|
269
|
+
...s.name !== void 0 ? { name: s.name } : {},
|
|
270
|
+
...s.claudeSessionId !== void 0 ? { claudeSessionId: s.claudeSessionId } : {}
|
|
271
|
+
}));
|
|
272
|
+
const data = JSON.stringify(persisted, null, 2);
|
|
273
|
+
const tmpPath = this.persistPath + ".tmp";
|
|
274
|
+
writeFileSync(tmpPath, data, "utf-8");
|
|
275
|
+
renameSync(tmpPath, this.persistPath);
|
|
276
|
+
}
|
|
277
|
+
load() {
|
|
278
|
+
if (!existsSync(this.persistPath)) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const raw = readFileSync(this.persistPath, "utf-8");
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(raw);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
throw new Error(`Failed to parse session persistence file at ${this.persistPath}`, {
|
|
287
|
+
cause: err
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(parsed)) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Session persistence file has invalid format at ${this.persistPath}: expected array`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
for (const item of parsed) {
|
|
296
|
+
if (item && typeof item === "object" && "state" in item) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Session persistence file has invalid persisted state for session ${String(
|
|
299
|
+
item.id
|
|
300
|
+
)}`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const info = item;
|
|
304
|
+
if (!isProviderId(info.provider)) {
|
|
305
|
+
const sessionId = String(info.id);
|
|
306
|
+
this.onSessionRemoved?.(sessionId);
|
|
307
|
+
serviceLogger.warn(
|
|
308
|
+
{ sessionId, provider: info.provider },
|
|
309
|
+
"Session persistence file has invalid provider; cleaning session"
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (info.mode === "pty") {
|
|
314
|
+
if (info.pid && this.isProcessAlive(info.pid)) {
|
|
315
|
+
serviceLogger.info(
|
|
316
|
+
{ sessionId: info.id, pid: info.pid },
|
|
317
|
+
"PTY session skipped on load, terminal alive"
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
this.onSessionRemoved?.(info.id);
|
|
321
|
+
serviceLogger.info(
|
|
322
|
+
{ sessionId: info.id, pid: info.pid },
|
|
323
|
+
"PTY session cleaned on load, terminal dead"
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (info.pid && this.isProcessAlive(info.pid)) {
|
|
329
|
+
this.sessions.set(info.id, { ...info, state: SessionState.IDLE });
|
|
330
|
+
} else {
|
|
331
|
+
this.onSessionRemoved?.(info.id);
|
|
332
|
+
serviceLogger.info(
|
|
333
|
+
{ sessionId: info.id, pid: info.pid },
|
|
334
|
+
"JSON session cleaned on load, worker dead"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
this.save();
|
|
339
|
+
if (this.sessions.size > 0) {
|
|
340
|
+
serviceLogger.info({ count: this.sessions.size }, "Sessions restored from persistence");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/serve/relay-connection.ts
|
|
346
|
+
import WebSocket from "ws";
|
|
347
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
348
|
+
import { homedir } from "os";
|
|
349
|
+
import { dirname as dirname2, join } from "path";
|
|
350
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
351
|
+
import { EventEmitter } from "events";
|
|
352
|
+
|
|
353
|
+
// src/serve/message-queue.ts
|
|
354
|
+
var MemoryMessageQueue = class {
|
|
355
|
+
items = [];
|
|
356
|
+
enqueue(raw) {
|
|
357
|
+
this.items.push(raw);
|
|
358
|
+
}
|
|
359
|
+
drain() {
|
|
360
|
+
const all = this.items;
|
|
361
|
+
this.items = [];
|
|
362
|
+
return all;
|
|
363
|
+
}
|
|
364
|
+
size() {
|
|
365
|
+
return this.items.length;
|
|
366
|
+
}
|
|
367
|
+
clear() {
|
|
368
|
+
this.items = [];
|
|
369
|
+
}
|
|
370
|
+
// 丢弃最旧消息,返回被丢弃的 raw 供 caller 做补偿(例如清理对应 pending 审批)
|
|
371
|
+
dropOldest() {
|
|
372
|
+
return this.items.shift() ?? null;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// src/serve/relay-connection.ts
|
|
377
|
+
var DEFAULT_PROXY_ID_PATH = join(homedir(), ".dev-anywhere", "proxy-id");
|
|
378
|
+
var MAX_BACKOFF_MS = 3e4;
|
|
379
|
+
var BASE_BACKOFF_MS = 1e3;
|
|
380
|
+
var MAX_QUEUE_SIZE = 1e4;
|
|
381
|
+
var RelayConnectionState = {
|
|
382
|
+
DISCONNECTED: "disconnected",
|
|
383
|
+
CONNECTING: "connecting",
|
|
384
|
+
REGISTERING: "registering",
|
|
385
|
+
SYNCED: "synced",
|
|
386
|
+
WAITING_RECONNECT: "waiting_reconnect",
|
|
387
|
+
CLOSED: "closed"
|
|
388
|
+
};
|
|
389
|
+
var RELAY_TRANSITIONS = {
|
|
390
|
+
[RelayConnectionState.DISCONNECTED]: [
|
|
391
|
+
RelayConnectionState.CONNECTING,
|
|
392
|
+
RelayConnectionState.CLOSED
|
|
393
|
+
],
|
|
394
|
+
[RelayConnectionState.CONNECTING]: [
|
|
395
|
+
RelayConnectionState.REGISTERING,
|
|
396
|
+
RelayConnectionState.WAITING_RECONNECT,
|
|
397
|
+
RelayConnectionState.CLOSED
|
|
398
|
+
],
|
|
399
|
+
[RelayConnectionState.REGISTERING]: [
|
|
400
|
+
RelayConnectionState.SYNCED,
|
|
401
|
+
RelayConnectionState.WAITING_RECONNECT,
|
|
402
|
+
RelayConnectionState.CLOSED
|
|
403
|
+
],
|
|
404
|
+
[RelayConnectionState.SYNCED]: [
|
|
405
|
+
RelayConnectionState.WAITING_RECONNECT,
|
|
406
|
+
RelayConnectionState.CLOSED
|
|
407
|
+
],
|
|
408
|
+
[RelayConnectionState.WAITING_RECONNECT]: [
|
|
409
|
+
RelayConnectionState.CONNECTING,
|
|
410
|
+
RelayConnectionState.CLOSED
|
|
411
|
+
],
|
|
412
|
+
[RelayConnectionState.CLOSED]: []
|
|
413
|
+
};
|
|
414
|
+
var RelayConnection = class extends EventEmitter {
|
|
415
|
+
ws = null;
|
|
416
|
+
proxyId;
|
|
417
|
+
relayUrl;
|
|
418
|
+
queue = new MemoryMessageQueue();
|
|
419
|
+
reconnectAttempt = 0;
|
|
420
|
+
reconnectTimer = null;
|
|
421
|
+
fsm = createFSM({
|
|
422
|
+
initial: RelayConnectionState.DISCONNECTED,
|
|
423
|
+
transitions: RELAY_TRANSITIONS,
|
|
424
|
+
onTransition: (from, to) => serviceLogger.info({ from, to }, "RelayConnection state transition"),
|
|
425
|
+
onRejected: (from, to, isAbsorbing) => serviceLogger[isAbsorbing ? "debug" : "warn"](
|
|
426
|
+
{ from, to },
|
|
427
|
+
isAbsorbing ? "Late event after absorbing state, ignored" : "Invalid relay connection transition rejected"
|
|
428
|
+
)
|
|
429
|
+
});
|
|
430
|
+
name;
|
|
431
|
+
token;
|
|
432
|
+
constructor(relayUrl, options) {
|
|
433
|
+
super();
|
|
434
|
+
this.relayUrl = relayUrl;
|
|
435
|
+
this.proxyId = this.loadOrCreateProxyId(options?.proxyIdPath ?? DEFAULT_PROXY_ID_PATH);
|
|
436
|
+
this.name = options?.name;
|
|
437
|
+
this.token = options?.token;
|
|
438
|
+
}
|
|
439
|
+
// 从文件读取或生成新的 proxyId,生成后持久化到文件
|
|
440
|
+
loadOrCreateProxyId(idPath) {
|
|
441
|
+
if (existsSync2(idPath)) {
|
|
442
|
+
const existing = readFileSync2(idPath, "utf-8").trim();
|
|
443
|
+
if (existing.length > 0) {
|
|
444
|
+
return existing;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const id = nanoid2(21);
|
|
448
|
+
const dir = dirname2(idPath);
|
|
449
|
+
if (!existsSync2(dir)) {
|
|
450
|
+
mkdirSync2(dir, { recursive: true });
|
|
451
|
+
}
|
|
452
|
+
writeFileSync2(idPath, id, "utf-8");
|
|
453
|
+
return id;
|
|
454
|
+
}
|
|
455
|
+
// 连接到 relay server
|
|
456
|
+
connect() {
|
|
457
|
+
if (!this.fsm.tryTransitionTo(RelayConnectionState.CONNECTING)) return;
|
|
458
|
+
this.doConnect();
|
|
459
|
+
}
|
|
460
|
+
// 实际建立 WebSocket 连接的内部方法
|
|
461
|
+
doConnect() {
|
|
462
|
+
try {
|
|
463
|
+
const base = this.relayUrl.replace(/\/$/, "") + "/proxy";
|
|
464
|
+
const url = this.token ? `${base}?token=${encodeURIComponent(this.token)}` : base;
|
|
465
|
+
this.ws = new WebSocket(url);
|
|
466
|
+
this.ws.on("open", () => {
|
|
467
|
+
if (!this.fsm.tryTransitionTo(RelayConnectionState.REGISTERING)) return;
|
|
468
|
+
serviceLogger.info(
|
|
469
|
+
{ proxyId: this.proxyId, url: base, tokenSet: !!this.token },
|
|
470
|
+
"Connected to relay server"
|
|
471
|
+
);
|
|
472
|
+
this.ws.send(
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
type: "proxy_register",
|
|
475
|
+
proxyId: this.proxyId,
|
|
476
|
+
...this.name ? { name: this.name } : {}
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
this.ws.on("message", (data) => {
|
|
481
|
+
const raw = data.toString();
|
|
482
|
+
let msg;
|
|
483
|
+
try {
|
|
484
|
+
msg = JSON.parse(raw);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
serviceLogger.warn({ error: String(err) }, "Non-JSON message from relay, dropped");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (msg.type === "proxy_register_response") {
|
|
490
|
+
serviceLogger.info({ status: msg.status }, "Received register response");
|
|
491
|
+
if (!this.fsm.tryTransitionTo(RelayConnectionState.SYNCED)) return;
|
|
492
|
+
this.reconnectAttempt = 0;
|
|
493
|
+
this.flushQueue();
|
|
494
|
+
this.emit("connected");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.emit("message", msg);
|
|
498
|
+
});
|
|
499
|
+
this.ws.on("close", () => {
|
|
500
|
+
this.ws = null;
|
|
501
|
+
if (this.fsm.current() !== RelayConnectionState.CLOSED) {
|
|
502
|
+
this.fsm.tryTransitionTo(RelayConnectionState.WAITING_RECONNECT);
|
|
503
|
+
serviceLogger.info("Relay connection closed unexpectedly");
|
|
504
|
+
this.emit("disconnected");
|
|
505
|
+
this.scheduleReconnect();
|
|
506
|
+
} else {
|
|
507
|
+
serviceLogger.info("Relay connection closed");
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
this.ws.on("error", (err) => {
|
|
511
|
+
serviceLogger.error({ error: String(err) }, "Relay connection error");
|
|
512
|
+
});
|
|
513
|
+
} catch (err) {
|
|
514
|
+
serviceLogger.error({ error: String(err) }, "Failed to create relay connection");
|
|
515
|
+
if (this.fsm.current() !== RelayConnectionState.CLOSED) {
|
|
516
|
+
this.fsm.tryTransitionTo(RelayConnectionState.WAITING_RECONNECT);
|
|
517
|
+
this.scheduleReconnect();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// 将队列中缓存的消息依次发送到 relay
|
|
522
|
+
flushQueue() {
|
|
523
|
+
for (const raw of this.queue.drain()) {
|
|
524
|
+
this.ws?.send(raw);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// 计算全抖动指数退避延迟并调度重连
|
|
528
|
+
scheduleReconnect() {
|
|
529
|
+
const backoff = Math.random() * Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * Math.pow(2, this.reconnectAttempt));
|
|
530
|
+
serviceLogger.info(
|
|
531
|
+
{ attempt: this.reconnectAttempt + 1, backoffMs: Math.round(backoff) },
|
|
532
|
+
"Scheduling reconnect"
|
|
533
|
+
);
|
|
534
|
+
this.reconnectTimer = setTimeout(() => {
|
|
535
|
+
this.reconnectAttempt++;
|
|
536
|
+
if (!this.fsm.tryTransitionTo(RelayConnectionState.CONNECTING)) return;
|
|
537
|
+
this.doConnect();
|
|
538
|
+
}, backoff);
|
|
539
|
+
}
|
|
540
|
+
// 发送 MessageEnvelope 到 relay,离线时自动入队
|
|
541
|
+
sendEnvelope(envelope) {
|
|
542
|
+
const raw = JSON.stringify(envelope);
|
|
543
|
+
this.sendRaw(raw);
|
|
544
|
+
}
|
|
545
|
+
// 发送 binary PTY 帧到 relay,断线时直接丢弃不入队
|
|
546
|
+
sendBinary(data) {
|
|
547
|
+
if (this.fsm.current() === RelayConnectionState.SYNCED && this.ws?.readyState === WebSocket.OPEN) {
|
|
548
|
+
this.ws.send(data);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// 发送原始 JSON 字符串到 relay,根据 connectionState 决定直发、入队或丢弃
|
|
552
|
+
sendRaw(raw) {
|
|
553
|
+
if (this.fsm.current() === RelayConnectionState.SYNCED && this.ws?.readyState === WebSocket.OPEN) {
|
|
554
|
+
this.ws.send(raw);
|
|
555
|
+
} else if (this.fsm.current() === RelayConnectionState.CLOSED) {
|
|
556
|
+
serviceLogger.warn("Message discarded: connection is closed");
|
|
557
|
+
} else {
|
|
558
|
+
if (this.queue.size() >= MAX_QUEUE_SIZE) {
|
|
559
|
+
const dropped = this.queue.dropOldest();
|
|
560
|
+
serviceLogger.warn(
|
|
561
|
+
{ maxSize: MAX_QUEUE_SIZE },
|
|
562
|
+
"Message queue overflow, oldest message dropped"
|
|
563
|
+
);
|
|
564
|
+
if (dropped !== null) this.emit("envelope_dropped", dropped);
|
|
565
|
+
}
|
|
566
|
+
this.queue.enqueue(raw);
|
|
567
|
+
serviceLogger.debug({ queueSize: this.queue.size() }, "Message queued during disconnect");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// 主动关闭连接,发送 proxy_disconnect 通知 relay 立即清理,不触发重连
|
|
571
|
+
close() {
|
|
572
|
+
if (this.fsm.is(RelayConnectionState.CLOSED)) return;
|
|
573
|
+
this.fsm.tryTransitionTo(RelayConnectionState.CLOSED);
|
|
574
|
+
if (this.reconnectTimer) {
|
|
575
|
+
clearTimeout(this.reconnectTimer);
|
|
576
|
+
this.reconnectTimer = null;
|
|
577
|
+
}
|
|
578
|
+
if (this.ws) {
|
|
579
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
580
|
+
this.ws.send(JSON.stringify({ type: "proxy_disconnect", proxyId: this.proxyId }));
|
|
581
|
+
}
|
|
582
|
+
this.ws.close();
|
|
583
|
+
this.ws = null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// 获取当前 proxyId
|
|
587
|
+
getProxyId() {
|
|
588
|
+
return this.proxyId;
|
|
589
|
+
}
|
|
590
|
+
// 获取连接状态摘要,用于 CLI status 输出
|
|
591
|
+
getStatus() {
|
|
592
|
+
return {
|
|
593
|
+
connected: this.fsm.current() === RelayConnectionState.SYNCED,
|
|
594
|
+
connectionState: this.fsm.current(),
|
|
595
|
+
proxyId: this.proxyId,
|
|
596
|
+
reconnectAttempt: this.reconnectAttempt,
|
|
597
|
+
queueDepth: this.queue.size()
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// src/common/config.ts
|
|
603
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
604
|
+
import { dirname as dirname3, isAbsolute } from "path";
|
|
605
|
+
function parsePort(value, source) {
|
|
606
|
+
if (!value) return void 0;
|
|
607
|
+
const port = Number(value);
|
|
608
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
609
|
+
throw new Error(`Invalid ${source}: expected TCP port 1-65535`);
|
|
610
|
+
}
|
|
611
|
+
return port;
|
|
612
|
+
}
|
|
613
|
+
function resolveFileConfig(fromFile, requestedEnv) {
|
|
614
|
+
if (!fromFile.envs) {
|
|
615
|
+
return {
|
|
616
|
+
envName: void 0,
|
|
617
|
+
envNameSource: fromFile.relayUrl || fromFile.relayToken || fromFile.hookPort ? "single" : "none",
|
|
618
|
+
config: fromFile
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const envName = requestedEnv ?? fromFile.defaultEnv ?? "local";
|
|
622
|
+
const config = fromFile.envs[envName];
|
|
623
|
+
if (!config) {
|
|
624
|
+
const available = Object.keys(fromFile.envs).sort();
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Unknown config env "${envName}". Available envs: ${available.length > 0 ? available.join(", ") : "(none)"}`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
envName,
|
|
631
|
+
envNameSource: requestedEnv ? "cli" : fromFile.defaultEnv ? "file" : "default",
|
|
632
|
+
config
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function readConfigFile() {
|
|
636
|
+
if (!existsSync3(CONFIG_PATH)) return {};
|
|
637
|
+
return JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
|
|
638
|
+
}
|
|
639
|
+
function agentCliField(provider) {
|
|
640
|
+
return provider === "claude" ? "claudeBin" : "codexBin";
|
|
641
|
+
}
|
|
642
|
+
function agentCliHistoryField(provider) {
|
|
643
|
+
return provider === "claude" ? "claudeBinHistory" : "codexBinHistory";
|
|
644
|
+
}
|
|
645
|
+
function validateAgentCliPath(path) {
|
|
646
|
+
const normalized = path.trim();
|
|
647
|
+
if (!normalized) throw new Error("\u8BF7\u8F93\u5165 CLI \u8DEF\u5F84");
|
|
648
|
+
if (!isAbsolute(normalized)) throw new Error("CLI \u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84");
|
|
649
|
+
return normalized;
|
|
650
|
+
}
|
|
651
|
+
function uniqueAbsolutePaths(paths) {
|
|
652
|
+
const seen = /* @__PURE__ */ new Set();
|
|
653
|
+
const result = [];
|
|
654
|
+
for (const path of paths) {
|
|
655
|
+
const normalized = path?.trim();
|
|
656
|
+
if (!normalized || !isAbsolute(normalized) || seen.has(normalized)) continue;
|
|
657
|
+
seen.add(normalized);
|
|
658
|
+
result.push(normalized);
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
}
|
|
662
|
+
function updateAgentCliPathInEnvConfig(config, provider, path) {
|
|
663
|
+
const field = agentCliField(provider);
|
|
664
|
+
const historyField = agentCliHistoryField(provider);
|
|
665
|
+
const history = uniqueAbsolutePaths([path, ...config[historyField] ?? []]).slice(0, 8);
|
|
666
|
+
return {
|
|
667
|
+
...config,
|
|
668
|
+
[field]: path,
|
|
669
|
+
[historyField]: history
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function loadConfig(options) {
|
|
673
|
+
let fromFile = {};
|
|
674
|
+
if (existsSync3(CONFIG_PATH)) {
|
|
675
|
+
try {
|
|
676
|
+
fromFile = readConfigFile();
|
|
677
|
+
} catch (err) {
|
|
678
|
+
serviceLogger.warn(
|
|
679
|
+
{ path: CONFIG_PATH, err: err instanceof Error ? err.message : String(err) },
|
|
680
|
+
"Failed to parse config file, falling back to env-only"
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
serviceLogger.debug({ path: CONFIG_PATH }, "Config file not found, using env-only");
|
|
685
|
+
}
|
|
686
|
+
const resolved = resolveFileConfig(fromFile, options?.envName);
|
|
687
|
+
const hookPortFromFile = resolved.config.hookPort ?? fromFile.hookPort;
|
|
688
|
+
const claudeBinFromFile = resolved.config.claudeBin ?? fromFile.claudeBin;
|
|
689
|
+
const codexBinFromFile = resolved.config.codexBin ?? fromFile.codexBin;
|
|
690
|
+
const claudeBinHistory = [
|
|
691
|
+
...resolved.config.claudeBinHistory ?? [],
|
|
692
|
+
...fromFile.claudeBinHistory ?? []
|
|
693
|
+
];
|
|
694
|
+
const codexBinHistory = [
|
|
695
|
+
...resolved.config.codexBinHistory ?? [],
|
|
696
|
+
...fromFile.codexBinHistory ?? []
|
|
697
|
+
];
|
|
698
|
+
const claudeBin = process.env.CLAUDE_BIN ?? claudeBinFromFile;
|
|
699
|
+
const codexBin = process.env.CODEX_BIN ?? codexBinFromFile;
|
|
700
|
+
const config = {
|
|
701
|
+
envName: resolved.envName,
|
|
702
|
+
relayUrl: process.env.RELAY_URL ?? resolved.config.relayUrl,
|
|
703
|
+
relayToken: process.env.RELAY_PROXY_TOKEN ?? resolved.config.relayToken,
|
|
704
|
+
hookPort: parsePort(process.env.DEV_ANYWHERE_HOOK_PORT, "DEV_ANYWHERE_HOOK_PORT") ?? hookPortFromFile ?? 17654,
|
|
705
|
+
claudeBin,
|
|
706
|
+
codexBin,
|
|
707
|
+
agentCliSuggestions: {
|
|
708
|
+
claude: uniqueAbsolutePaths([process.env.CLAUDE_BIN, claudeBinFromFile, ...claudeBinHistory]),
|
|
709
|
+
codex: uniqueAbsolutePaths([process.env.CODEX_BIN, codexBinFromFile, ...codexBinHistory])
|
|
710
|
+
},
|
|
711
|
+
sources: {
|
|
712
|
+
envName: resolved.envNameSource,
|
|
713
|
+
relayUrl: process.env.RELAY_URL ? "env" : resolved.config.relayUrl ? "file" : "none",
|
|
714
|
+
relayToken: process.env.RELAY_PROXY_TOKEN ? "env" : resolved.config.relayToken ? "file" : "none",
|
|
715
|
+
hookPort: process.env.DEV_ANYWHERE_HOOK_PORT ? "env" : hookPortFromFile ? "file" : "default",
|
|
716
|
+
claudeBin: process.env.CLAUDE_BIN ? "env" : claudeBinFromFile ? "file" : "none",
|
|
717
|
+
codexBin: process.env.CODEX_BIN ? "env" : codexBinFromFile ? "file" : "none"
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
serviceLogger.info(
|
|
721
|
+
{
|
|
722
|
+
envName: config.envName ?? "(single)",
|
|
723
|
+
envNameSource: config.sources.envName,
|
|
724
|
+
relayUrl: config.relayUrl ?? "(unset)",
|
|
725
|
+
relayUrlSource: config.sources.relayUrl,
|
|
726
|
+
relayTokenSource: config.sources.relayToken,
|
|
727
|
+
hookPort: config.hookPort,
|
|
728
|
+
hookPortSource: config.sources.hookPort,
|
|
729
|
+
claudeBinSource: config.sources.claudeBin,
|
|
730
|
+
codexBinSource: config.sources.codexBin
|
|
731
|
+
},
|
|
732
|
+
"Config loaded"
|
|
733
|
+
);
|
|
734
|
+
return config;
|
|
735
|
+
}
|
|
736
|
+
function buildProviderEnv(config, baseEnv = process.env) {
|
|
737
|
+
return {
|
|
738
|
+
...baseEnv,
|
|
739
|
+
...config.claudeBin ? { CLAUDE_BIN: config.claudeBin } : {},
|
|
740
|
+
...config.codexBin ? { CODEX_BIN: config.codexBin } : {}
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function saveAgentCliPath(provider, path, options) {
|
|
744
|
+
const normalized = validateAgentCliPath(path);
|
|
745
|
+
const fromFile = readConfigFile();
|
|
746
|
+
const resolved = resolveFileConfig(fromFile, options?.envName);
|
|
747
|
+
if (fromFile.envs) {
|
|
748
|
+
const envName = resolved.envName ?? options?.envName ?? fromFile.defaultEnv ?? "local";
|
|
749
|
+
fromFile.envs[envName] = updateAgentCliPathInEnvConfig(
|
|
750
|
+
fromFile.envs[envName] ?? {},
|
|
751
|
+
provider,
|
|
752
|
+
normalized
|
|
753
|
+
);
|
|
754
|
+
} else {
|
|
755
|
+
Object.assign(fromFile, updateAgentCliPathInEnvConfig(fromFile, provider, normalized));
|
|
756
|
+
}
|
|
757
|
+
mkdirSync3(dirname3(CONFIG_PATH), { recursive: true });
|
|
758
|
+
writeFileSync3(CONFIG_PATH, `${JSON.stringify(fromFile, null, 2)}
|
|
759
|
+
`, "utf-8");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/serve/handlers/control-messages.ts
|
|
763
|
+
import { readdir as readdir2, mkdir } from "fs/promises";
|
|
764
|
+
import { join as join4, isAbsolute as isAbsolute2, normalize } from "path";
|
|
765
|
+
|
|
766
|
+
// src/serve/session-history.ts
|
|
767
|
+
import { readdir, stat, access } from "fs/promises";
|
|
768
|
+
import { createReadStream } from "fs";
|
|
769
|
+
import { join as join2 } from "path";
|
|
770
|
+
import { homedir as homedir2 } from "os";
|
|
771
|
+
import { createInterface } from "readline";
|
|
772
|
+
var claudeProjectsDir = () => join2(homedir2(), ".claude", "projects");
|
|
773
|
+
var codexSessionsDir = () => join2(homedir2(), ".codex", "sessions");
|
|
774
|
+
var UNTITLED_SESSION_TITLE = "\u672A\u547D\u540D\u4F1A\u8BDD";
|
|
775
|
+
var MAX_HISTORY_TITLE_LENGTH = 40;
|
|
776
|
+
var IGNORED_SLASH_COMMANDS = /* @__PURE__ */ new Set([
|
|
777
|
+
"/clear",
|
|
778
|
+
"/model",
|
|
779
|
+
"/compact",
|
|
780
|
+
"/help",
|
|
781
|
+
"/config",
|
|
782
|
+
"/logout"
|
|
783
|
+
]);
|
|
784
|
+
var XMLISH_NOISE_PREFIXES = [
|
|
785
|
+
"environment",
|
|
786
|
+
"system",
|
|
787
|
+
"developer",
|
|
788
|
+
"assistant",
|
|
789
|
+
"user",
|
|
790
|
+
"tool",
|
|
791
|
+
"context"
|
|
792
|
+
];
|
|
793
|
+
var INTERNAL_TITLE_PATTERNS = [
|
|
794
|
+
/^the following is the codex agent history\b/i,
|
|
795
|
+
/^codex agent history\b/i,
|
|
796
|
+
/^conversation summary\b/i
|
|
797
|
+
];
|
|
798
|
+
async function scanSessionHistory() {
|
|
799
|
+
const entries = [...await scanClaudeSessionHistory(), ...await scanCodexSessionHistory()];
|
|
800
|
+
entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
801
|
+
const seen = /* @__PURE__ */ new Set();
|
|
802
|
+
return entries.filter((e) => {
|
|
803
|
+
const key = `${e.provider}::${e.projectDir}::${e.title}`;
|
|
804
|
+
if (seen.has(key)) return false;
|
|
805
|
+
seen.add(key);
|
|
806
|
+
return true;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async function scanClaudeSessionHistory() {
|
|
810
|
+
const entries = [];
|
|
811
|
+
let projectDirs;
|
|
812
|
+
try {
|
|
813
|
+
projectDirs = await readdir(claudeProjectsDir());
|
|
814
|
+
} catch {
|
|
815
|
+
return [];
|
|
816
|
+
}
|
|
817
|
+
for (const encodedDir of projectDirs) {
|
|
818
|
+
const projectPath = join2(claudeProjectsDir(), encodedDir);
|
|
819
|
+
let files;
|
|
820
|
+
try {
|
|
821
|
+
files = await readdir(projectPath);
|
|
822
|
+
} catch {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
for (const file of files) {
|
|
826
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
827
|
+
const filePath = join2(projectPath, file);
|
|
828
|
+
try {
|
|
829
|
+
const fileStat = await stat(filePath);
|
|
830
|
+
const sessionId = file.replace(/\.jsonl$/, "");
|
|
831
|
+
const { title, cwd } = await extractTitleAndCwd(filePath);
|
|
832
|
+
entries.push({
|
|
833
|
+
id: sessionId,
|
|
834
|
+
title: title || UNTITLED_SESSION_TITLE,
|
|
835
|
+
projectDir: cwd || "/" + encodedDir.replace(/^-/, "").split("-").join("/"),
|
|
836
|
+
updatedAt: fileStat.mtimeMs,
|
|
837
|
+
provider: "claude"
|
|
838
|
+
});
|
|
839
|
+
} catch {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return entries;
|
|
845
|
+
}
|
|
846
|
+
async function scanCodexSessionHistory() {
|
|
847
|
+
const files = await collectJsonlFiles(codexSessionsDir());
|
|
848
|
+
const entries = [];
|
|
849
|
+
for (const filePath of files) {
|
|
850
|
+
try {
|
|
851
|
+
const fileStat = await stat(filePath);
|
|
852
|
+
const meta = await extractCodexTitleAndCwd(filePath);
|
|
853
|
+
if (!meta.id) continue;
|
|
854
|
+
entries.push({
|
|
855
|
+
id: meta.id,
|
|
856
|
+
title: meta.title || UNTITLED_SESSION_TITLE,
|
|
857
|
+
projectDir: meta.cwd || homedir2(),
|
|
858
|
+
updatedAt: fileStat.mtimeMs,
|
|
859
|
+
provider: "codex"
|
|
860
|
+
});
|
|
861
|
+
} catch {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return entries;
|
|
866
|
+
}
|
|
867
|
+
async function readSessionMessages(claudeSessionId) {
|
|
868
|
+
let projectDirs;
|
|
869
|
+
try {
|
|
870
|
+
projectDirs = await readdir(claudeProjectsDir());
|
|
871
|
+
} catch {
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
for (const encodedDir of projectDirs) {
|
|
875
|
+
const filePath = join2(claudeProjectsDir(), encodedDir, `${claudeSessionId}.jsonl`);
|
|
876
|
+
try {
|
|
877
|
+
await access(filePath);
|
|
878
|
+
} catch {
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
const messages = [];
|
|
882
|
+
return new Promise((resolve) => {
|
|
883
|
+
const rl = createInterface({
|
|
884
|
+
input: createReadStream(filePath, { encoding: "utf-8" }),
|
|
885
|
+
crlfDelay: Infinity
|
|
886
|
+
});
|
|
887
|
+
rl.on("line", (line) => {
|
|
888
|
+
if (!line.trim()) return;
|
|
889
|
+
try {
|
|
890
|
+
const obj = JSON.parse(line);
|
|
891
|
+
if (obj.type === "user") {
|
|
892
|
+
if (obj.isMeta) return;
|
|
893
|
+
const text = extractConversationText(obj.message);
|
|
894
|
+
if (!text) return;
|
|
895
|
+
const ts = typeof obj.timestamp === "string" ? new Date(obj.timestamp).getTime() : void 0;
|
|
896
|
+
messages.push({ role: "user", text, timestamp: ts });
|
|
897
|
+
} else if (obj.type === "assistant") {
|
|
898
|
+
const text = extractConversationText(obj.message);
|
|
899
|
+
const ts = typeof obj.timestamp === "string" ? new Date(obj.timestamp).getTime() : void 0;
|
|
900
|
+
if (text) messages.push({ role: "assistant", text, timestamp: ts });
|
|
901
|
+
}
|
|
902
|
+
} catch {
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
rl.on("close", () => resolve(messages));
|
|
906
|
+
rl.on("error", () => resolve(messages));
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
return [];
|
|
910
|
+
}
|
|
911
|
+
function collapseWhitespace(text) {
|
|
912
|
+
return text.replace(/\s+/g, " ").trim();
|
|
913
|
+
}
|
|
914
|
+
function truncateTitle(text) {
|
|
915
|
+
const chars = Array.from(text);
|
|
916
|
+
return chars.length > MAX_HISTORY_TITLE_LENGTH ? `${chars.slice(0, MAX_HISTORY_TITLE_LENGTH).join("")}...` : text;
|
|
917
|
+
}
|
|
918
|
+
function isXmlishNoise(text) {
|
|
919
|
+
const match = text.match(/^<([A-Za-z][\w:-]*)\b/);
|
|
920
|
+
if (!match) return false;
|
|
921
|
+
const tag = match[1].toLowerCase();
|
|
922
|
+
return XMLISH_NOISE_PREFIXES.some((prefix) => tag === prefix || tag.startsWith(`${prefix}_`));
|
|
923
|
+
}
|
|
924
|
+
function normalizeHistoryTitle(raw) {
|
|
925
|
+
if (!raw) return null;
|
|
926
|
+
const text = collapseWhitespace(raw);
|
|
927
|
+
if (text.length < 2) return null;
|
|
928
|
+
if (text.startsWith("<") || isXmlishNoise(text)) return null;
|
|
929
|
+
if (INTERNAL_TITLE_PATTERNS.some((pattern) => pattern.test(text))) return null;
|
|
930
|
+
const slashCommand = text.match(/^\/\S+/)?.[0];
|
|
931
|
+
if (slashCommand && IGNORED_SLASH_COMMANDS.has(slashCommand)) return null;
|
|
932
|
+
return truncateTitle(text);
|
|
933
|
+
}
|
|
934
|
+
function extractSlashCommand(text) {
|
|
935
|
+
const nameMatch = text.match(/<command-name>([^<]+)<\/command-name>/);
|
|
936
|
+
if (!nameMatch) return null;
|
|
937
|
+
const argsMatch = text.match(/<command-args>([^<]+)<\/command-args>/);
|
|
938
|
+
const args = argsMatch ? argsMatch[1].trim() : "";
|
|
939
|
+
return normalizeHistoryTitle(args ? `${nameMatch[1]} ${args}` : nameMatch[1]);
|
|
940
|
+
}
|
|
941
|
+
function extractMessageText(msg) {
|
|
942
|
+
if (typeof msg === "string") {
|
|
943
|
+
const cmd = extractSlashCommand(msg);
|
|
944
|
+
if (cmd) return cmd;
|
|
945
|
+
return normalizeHistoryTitle(msg);
|
|
946
|
+
}
|
|
947
|
+
if (msg && typeof msg === "object" && "content" in msg) {
|
|
948
|
+
const content = msg.content;
|
|
949
|
+
if (typeof content === "string") {
|
|
950
|
+
const cmd = extractSlashCommand(content);
|
|
951
|
+
if (cmd) return cmd;
|
|
952
|
+
return normalizeHistoryTitle(content);
|
|
953
|
+
}
|
|
954
|
+
if (Array.isArray(content)) {
|
|
955
|
+
const texts = content.filter(
|
|
956
|
+
(b) => b.type === "text" && typeof b.text === "string"
|
|
957
|
+
).map((b) => b.text);
|
|
958
|
+
const joined = texts.join("\n").trim();
|
|
959
|
+
return normalizeHistoryTitle(joined);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (Array.isArray(msg)) {
|
|
963
|
+
const texts = msg.filter(
|
|
964
|
+
(b) => b.type === "text" && typeof b.text === "string"
|
|
965
|
+
).map((b) => b.text);
|
|
966
|
+
const joined = texts.join("\n").trim();
|
|
967
|
+
return normalizeHistoryTitle(joined);
|
|
968
|
+
}
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
function normalizeConversationText(text) {
|
|
972
|
+
const trimmed = text.trim();
|
|
973
|
+
if (!trimmed) return null;
|
|
974
|
+
return trimmed;
|
|
975
|
+
}
|
|
976
|
+
function extractConversationText(msg) {
|
|
977
|
+
if (typeof msg === "string") {
|
|
978
|
+
const cmd = extractSlashCommand(msg);
|
|
979
|
+
if (cmd) return cmd;
|
|
980
|
+
return normalizeConversationText(msg);
|
|
981
|
+
}
|
|
982
|
+
if (msg && typeof msg === "object" && "content" in msg) {
|
|
983
|
+
const content = msg.content;
|
|
984
|
+
if (typeof content === "string") {
|
|
985
|
+
const cmd = extractSlashCommand(content);
|
|
986
|
+
if (cmd) return cmd;
|
|
987
|
+
return normalizeConversationText(content);
|
|
988
|
+
}
|
|
989
|
+
if (Array.isArray(content)) {
|
|
990
|
+
const texts = content.filter(
|
|
991
|
+
(b) => b.type === "text" && typeof b.text === "string"
|
|
992
|
+
).map((b) => b.text);
|
|
993
|
+
return normalizeConversationText(texts.join("\n"));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (Array.isArray(msg)) {
|
|
997
|
+
const texts = msg.filter(
|
|
998
|
+
(b) => b.type === "text" && typeof b.text === "string"
|
|
999
|
+
).map((b) => b.text);
|
|
1000
|
+
return normalizeConversationText(texts.join("\n"));
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
async function extractTitleAndCwd(filePath) {
|
|
1005
|
+
return new Promise((resolve) => {
|
|
1006
|
+
const rl = createInterface({
|
|
1007
|
+
input: createReadStream(filePath, { encoding: "utf-8" }),
|
|
1008
|
+
crlfDelay: Infinity
|
|
1009
|
+
});
|
|
1010
|
+
let resolved = false;
|
|
1011
|
+
let cwd = null;
|
|
1012
|
+
let title = null;
|
|
1013
|
+
rl.on("line", (line) => {
|
|
1014
|
+
if (resolved) return;
|
|
1015
|
+
if (!line.trim()) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const obj = JSON.parse(line);
|
|
1018
|
+
if (!cwd && typeof obj.cwd === "string") {
|
|
1019
|
+
cwd = obj.cwd;
|
|
1020
|
+
}
|
|
1021
|
+
if (!title && obj.type === "user" && !obj.isMeta) {
|
|
1022
|
+
const text = extractMessageText(obj.message);
|
|
1023
|
+
if (text) title = text;
|
|
1024
|
+
}
|
|
1025
|
+
if (cwd && title) {
|
|
1026
|
+
resolved = true;
|
|
1027
|
+
rl.close();
|
|
1028
|
+
}
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
rl.on("close", () => {
|
|
1033
|
+
if (!resolved) resolve({ title, cwd });
|
|
1034
|
+
else resolve({ title, cwd });
|
|
1035
|
+
});
|
|
1036
|
+
rl.on("error", () => resolve({ title, cwd }));
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async function collectJsonlFiles(root) {
|
|
1040
|
+
let entries;
|
|
1041
|
+
try {
|
|
1042
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
1043
|
+
} catch {
|
|
1044
|
+
return [];
|
|
1045
|
+
}
|
|
1046
|
+
const files = [];
|
|
1047
|
+
for (const entry of entries) {
|
|
1048
|
+
const child = join2(root, entry.name);
|
|
1049
|
+
if (entry.isDirectory()) {
|
|
1050
|
+
files.push(...await collectJsonlFiles(child));
|
|
1051
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
1052
|
+
files.push(child);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return files;
|
|
1056
|
+
}
|
|
1057
|
+
async function extractCodexTitleAndCwd(filePath) {
|
|
1058
|
+
return new Promise((resolve) => {
|
|
1059
|
+
const rl = createInterface({
|
|
1060
|
+
input: createReadStream(filePath, { encoding: "utf-8" }),
|
|
1061
|
+
crlfDelay: Infinity
|
|
1062
|
+
});
|
|
1063
|
+
let id = null;
|
|
1064
|
+
let cwd = null;
|
|
1065
|
+
let title = null;
|
|
1066
|
+
rl.on("line", (line) => {
|
|
1067
|
+
if (!line.trim()) return;
|
|
1068
|
+
try {
|
|
1069
|
+
const obj = JSON.parse(line);
|
|
1070
|
+
if (obj.type === "session_meta" && obj.payload) {
|
|
1071
|
+
if (!id && typeof obj.payload.id === "string") id = obj.payload.id;
|
|
1072
|
+
if (!cwd && typeof obj.payload.cwd === "string") cwd = obj.payload.cwd;
|
|
1073
|
+
}
|
|
1074
|
+
if (!title && obj.type === "response_item") {
|
|
1075
|
+
const text = extractCodexUserText(obj.payload);
|
|
1076
|
+
if (text) title = text;
|
|
1077
|
+
}
|
|
1078
|
+
if (id && cwd && title) rl.close();
|
|
1079
|
+
} catch {
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
rl.on("close", () => resolve({ id, title, cwd }));
|
|
1083
|
+
rl.on("error", () => resolve({ id, title, cwd }));
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
function extractCodexUserText(payload) {
|
|
1087
|
+
if (!payload || typeof payload !== "object") return null;
|
|
1088
|
+
const item = payload;
|
|
1089
|
+
if (item.type !== "message" || item.role !== "user") return null;
|
|
1090
|
+
if (typeof item.content === "string") return normalizeHistoryTitle(item.content);
|
|
1091
|
+
if (!Array.isArray(item.content)) return null;
|
|
1092
|
+
const texts = item.content.map((block) => {
|
|
1093
|
+
if (!block || typeof block !== "object") return "";
|
|
1094
|
+
const typed = block;
|
|
1095
|
+
return typed.type === "input_text" && typeof typed.text === "string" ? typed.text : "";
|
|
1096
|
+
}).filter(Boolean);
|
|
1097
|
+
const joined = texts.join("\n").trim();
|
|
1098
|
+
return normalizeHistoryTitle(joined);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/serve/command-discovery.ts
|
|
1102
|
+
import { readdirSync, readFileSync as readFileSync4 } from "fs";
|
|
1103
|
+
import { homedir as homedir3 } from "os";
|
|
1104
|
+
import { join as join3 } from "path";
|
|
1105
|
+
var REPL_BUILTINS = [
|
|
1106
|
+
{ name: "/compact", description: "Compact conversation history", source: "builtin" },
|
|
1107
|
+
{ name: "/status", description: "Show session status", source: "builtin" },
|
|
1108
|
+
{ name: "/cost", description: "Show token usage and cost", source: "builtin" },
|
|
1109
|
+
{ name: "/clear", description: "Clear conversation history", source: "builtin" },
|
|
1110
|
+
{
|
|
1111
|
+
name: "/model",
|
|
1112
|
+
description: "Switch AI model",
|
|
1113
|
+
argumentHint: "model name (e.g., Haiku, Sonnet)",
|
|
1114
|
+
source: "builtin"
|
|
1115
|
+
},
|
|
1116
|
+
{ name: "/help", description: "Show available commands", source: "builtin" },
|
|
1117
|
+
{ name: "/memory", description: "Edit CLAUDE.md memory", source: "builtin" },
|
|
1118
|
+
{ name: "/review", description: "Review diff of changes", source: "builtin" },
|
|
1119
|
+
{ name: "/vim", description: "Enter vim mode", source: "builtin" },
|
|
1120
|
+
{ name: "/terminal-setup", description: "Configure terminal integration", source: "builtin" },
|
|
1121
|
+
{ name: "/permissions", description: "View and manage permissions", source: "builtin" },
|
|
1122
|
+
{ name: "/allowed-tools", description: "View allowed tools", source: "builtin" },
|
|
1123
|
+
{
|
|
1124
|
+
name: "/add-dir",
|
|
1125
|
+
description: "Add working directory",
|
|
1126
|
+
argumentHint: "directory path",
|
|
1127
|
+
source: "builtin"
|
|
1128
|
+
},
|
|
1129
|
+
{ name: "/init", description: "Initialize CLAUDE.md in project", source: "builtin" },
|
|
1130
|
+
{ name: "/listen", description: "Listen for multi-turn responses", source: "builtin" },
|
|
1131
|
+
{ name: "/pr-comments", description: "View PR comments", source: "builtin" },
|
|
1132
|
+
{ name: "/release-notes", description: "Generate release notes", source: "builtin" },
|
|
1133
|
+
{ name: "/ide", description: "Open IDE integration", source: "builtin" }
|
|
1134
|
+
];
|
|
1135
|
+
var COMMAND_BLACKLIST = /* @__PURE__ */ new Set([
|
|
1136
|
+
"/login",
|
|
1137
|
+
"/logout",
|
|
1138
|
+
"/config",
|
|
1139
|
+
"/plugin",
|
|
1140
|
+
"/mcp",
|
|
1141
|
+
"/install",
|
|
1142
|
+
"/setup-token",
|
|
1143
|
+
"/doctor",
|
|
1144
|
+
"/update",
|
|
1145
|
+
"/upgrade",
|
|
1146
|
+
"/memory",
|
|
1147
|
+
"/vim",
|
|
1148
|
+
"/terminal-setup",
|
|
1149
|
+
"/permissions",
|
|
1150
|
+
"/allowed-tools",
|
|
1151
|
+
"/ide",
|
|
1152
|
+
"/listen"
|
|
1153
|
+
]);
|
|
1154
|
+
function parseSkillFrontmatter(content) {
|
|
1155
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
1156
|
+
if (!match) return {};
|
|
1157
|
+
const yaml = match[1];
|
|
1158
|
+
const result = {};
|
|
1159
|
+
const nameMatch = yaml.match(/^name:\s*(.+)$/m);
|
|
1160
|
+
if (nameMatch) result.name = nameMatch[1].trim();
|
|
1161
|
+
const descMatch = yaml.match(/^description:\s*(.+)$/m);
|
|
1162
|
+
if (descMatch) result.description = descMatch[1].trim();
|
|
1163
|
+
const hintMatch = yaml.match(/^argument-hint:\s*(.+)$/m);
|
|
1164
|
+
if (hintMatch) result.argumentHint = hintMatch[1].trim();
|
|
1165
|
+
return result;
|
|
1166
|
+
}
|
|
1167
|
+
function scanSkillsDir(dirPath, source) {
|
|
1168
|
+
let entries;
|
|
1169
|
+
try {
|
|
1170
|
+
entries = readdirSync(dirPath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return [];
|
|
1173
|
+
}
|
|
1174
|
+
const commands = [];
|
|
1175
|
+
for (const name of entries) {
|
|
1176
|
+
const skillPath = join3(dirPath, name, "SKILL.md");
|
|
1177
|
+
try {
|
|
1178
|
+
const content = readFileSync4(skillPath, "utf-8");
|
|
1179
|
+
const parsed = parseSkillFrontmatter(content);
|
|
1180
|
+
commands.push({
|
|
1181
|
+
name: `/${parsed.name ?? name}`,
|
|
1182
|
+
description: parsed.description ?? "",
|
|
1183
|
+
argumentHint: parsed.argumentHint,
|
|
1184
|
+
source
|
|
1185
|
+
});
|
|
1186
|
+
} catch {
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return commands;
|
|
1190
|
+
}
|
|
1191
|
+
function scanCommandsDir(dirPath, source) {
|
|
1192
|
+
let entries;
|
|
1193
|
+
try {
|
|
1194
|
+
entries = readdirSync(dirPath).filter((f) => f.endsWith(".md"));
|
|
1195
|
+
} catch {
|
|
1196
|
+
return [];
|
|
1197
|
+
}
|
|
1198
|
+
const commands = [];
|
|
1199
|
+
for (const filename of entries) {
|
|
1200
|
+
const cmdName = filename.replace(/\.md$/, "");
|
|
1201
|
+
try {
|
|
1202
|
+
const content = readFileSync4(join3(dirPath, filename), "utf-8");
|
|
1203
|
+
const firstLine = content.split("\n")[0].trim();
|
|
1204
|
+
commands.push({
|
|
1205
|
+
name: `/${cmdName}`,
|
|
1206
|
+
description: firstLine,
|
|
1207
|
+
source
|
|
1208
|
+
});
|
|
1209
|
+
} catch {
|
|
1210
|
+
commands.push({
|
|
1211
|
+
name: `/${cmdName}`,
|
|
1212
|
+
description: "",
|
|
1213
|
+
source
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
return commands;
|
|
1218
|
+
}
|
|
1219
|
+
function scanPluginDirs(homeDir) {
|
|
1220
|
+
const pluginCacheDir = join3(homeDir, ".claude", "plugins", "cache");
|
|
1221
|
+
let pluginNames;
|
|
1222
|
+
try {
|
|
1223
|
+
pluginNames = readdirSync(pluginCacheDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1224
|
+
} catch {
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
1227
|
+
const commands = [];
|
|
1228
|
+
for (const pluginName of pluginNames) {
|
|
1229
|
+
const pluginDir = join3(pluginCacheDir, pluginName);
|
|
1230
|
+
const skillCmds = scanSkillsDir(join3(pluginDir, "skills"), "plugin-skill");
|
|
1231
|
+
const cmdCmds = scanCommandsDir(join3(pluginDir, "commands"), "plugin-command");
|
|
1232
|
+
commands.push(...skillCmds, ...cmdCmds);
|
|
1233
|
+
}
|
|
1234
|
+
return commands;
|
|
1235
|
+
}
|
|
1236
|
+
async function discoverCommands(workDir, options) {
|
|
1237
|
+
const homeDir = options?.homeDir ?? homedir3();
|
|
1238
|
+
const builtins = REPL_BUILTINS.filter((c) => !COMMAND_BLACKLIST.has(c.name));
|
|
1239
|
+
const userSkills = scanSkillsDir(join3(homeDir, ".claude", "skills"), "user-skill");
|
|
1240
|
+
const projectSkills = scanSkillsDir(join3(workDir, ".claude", "skills"), "project-skill");
|
|
1241
|
+
const userCommands = scanCommandsDir(join3(homeDir, ".claude", "commands"), "user-command");
|
|
1242
|
+
const projectCommands = scanCommandsDir(join3(workDir, ".claude", "commands"), "project-command");
|
|
1243
|
+
const pluginCommands = scanPluginDirs(homeDir);
|
|
1244
|
+
const commandMap = /* @__PURE__ */ new Map();
|
|
1245
|
+
for (const cmd of builtins) commandMap.set(cmd.name, cmd);
|
|
1246
|
+
for (const cmd of pluginCommands) commandMap.set(cmd.name, cmd);
|
|
1247
|
+
for (const cmd of userSkills) commandMap.set(cmd.name, cmd);
|
|
1248
|
+
for (const cmd of userCommands) commandMap.set(cmd.name, cmd);
|
|
1249
|
+
for (const cmd of projectSkills) commandMap.set(cmd.name, cmd);
|
|
1250
|
+
for (const cmd of projectCommands) commandMap.set(cmd.name, cmd);
|
|
1251
|
+
const result = [];
|
|
1252
|
+
for (const cmd of commandMap.values()) {
|
|
1253
|
+
if (!COMMAND_BLACKLIST.has(cmd.name)) {
|
|
1254
|
+
result.push(cmd);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return result;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/serve/path-errors.ts
|
|
1261
|
+
function getFsErrorCode(err) {
|
|
1262
|
+
return typeof err === "object" && err !== null && "code" in err ? String(err.code) : void 0;
|
|
1263
|
+
}
|
|
1264
|
+
function classifyPathError(err) {
|
|
1265
|
+
switch (getFsErrorCode(err)) {
|
|
1266
|
+
case "ENOENT":
|
|
1267
|
+
return ControlErrorCode.PATH_NOT_FOUND;
|
|
1268
|
+
case "ENOTDIR":
|
|
1269
|
+
return ControlErrorCode.PATH_NOT_DIRECTORY;
|
|
1270
|
+
case "EACCES":
|
|
1271
|
+
case "EPERM":
|
|
1272
|
+
return ControlErrorCode.PATH_ACCESS_DENIED;
|
|
1273
|
+
default:
|
|
1274
|
+
return ControlErrorCode.UNKNOWN;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/serve/handlers/control-messages.ts
|
|
1279
|
+
var COMMAND_REFRESH_MS = 6 * 60 * 60 * 1e3;
|
|
1280
|
+
function isPathSafe(path) {
|
|
1281
|
+
if (!isAbsolute2(path)) return false;
|
|
1282
|
+
const normalized = normalize(path);
|
|
1283
|
+
if (normalized.includes("..")) return false;
|
|
1284
|
+
return true;
|
|
1285
|
+
}
|
|
1286
|
+
var HIDDEN_ENTRY_NAMES = /* @__PURE__ */ new Set(["node_modules"]);
|
|
1287
|
+
function isPickerVisible(name) {
|
|
1288
|
+
return !name.startsWith(".") && !HIDDEN_ENTRY_NAMES.has(name);
|
|
1289
|
+
}
|
|
1290
|
+
function sortEntries(a, b) {
|
|
1291
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
1292
|
+
return a.name.localeCompare(b.name);
|
|
1293
|
+
}
|
|
1294
|
+
async function scanDir(dirPath) {
|
|
1295
|
+
const entries = await readdir2(dirPath, { withFileTypes: true });
|
|
1296
|
+
return entries.filter((e) => isPickerVisible(e.name)).map((e) => ({ name: e.name, isDir: e.isDirectory() })).sort(sortEntries);
|
|
1297
|
+
}
|
|
1298
|
+
async function getFileTree(rootPath) {
|
|
1299
|
+
const groups = [];
|
|
1300
|
+
let rootEntries;
|
|
1301
|
+
try {
|
|
1302
|
+
rootEntries = await scanDir(rootPath);
|
|
1303
|
+
} catch {
|
|
1304
|
+
return groups;
|
|
1305
|
+
}
|
|
1306
|
+
groups.push({ path: rootPath, entries: rootEntries });
|
|
1307
|
+
for (const sub of rootEntries) {
|
|
1308
|
+
if (!sub.isDir) continue;
|
|
1309
|
+
const subPath = join4(rootPath, sub.name);
|
|
1310
|
+
try {
|
|
1311
|
+
const subEntries = await scanDir(subPath);
|
|
1312
|
+
groups.push({ path: subPath, entries: subEntries });
|
|
1313
|
+
} catch {
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return groups;
|
|
1317
|
+
}
|
|
1318
|
+
function createControlMessageHandlers(send, sessionManager) {
|
|
1319
|
+
const sessionResources = /* @__PURE__ */ new Map();
|
|
1320
|
+
function getResources(sessionId) {
|
|
1321
|
+
let res = sessionResources.get(sessionId);
|
|
1322
|
+
if (!res) {
|
|
1323
|
+
res = {};
|
|
1324
|
+
sessionResources.set(sessionId, res);
|
|
1325
|
+
}
|
|
1326
|
+
return res;
|
|
1327
|
+
}
|
|
1328
|
+
function scheduleCommandRefresh(sessionId, workDir) {
|
|
1329
|
+
const resources = getResources(sessionId);
|
|
1330
|
+
if (resources.commandRefreshTimer) {
|
|
1331
|
+
clearInterval(resources.commandRefreshTimer);
|
|
1332
|
+
}
|
|
1333
|
+
resources.commandRefreshTimer = setInterval(async () => {
|
|
1334
|
+
try {
|
|
1335
|
+
const commands = await discoverCommands(workDir);
|
|
1336
|
+
send(
|
|
1337
|
+
JSON.stringify({
|
|
1338
|
+
type: "command_list_push",
|
|
1339
|
+
commands
|
|
1340
|
+
})
|
|
1341
|
+
);
|
|
1342
|
+
serviceLogger.debug({ sessionId, count: commands.length }, "Command list refreshed");
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
serviceLogger.warn({ sessionId, error: String(err) }, "Command refresh failed");
|
|
1345
|
+
}
|
|
1346
|
+
}, COMMAND_REFRESH_MS);
|
|
1347
|
+
}
|
|
1348
|
+
return {
|
|
1349
|
+
async handleDirListRequest(msg) {
|
|
1350
|
+
if (!isPathSafe(msg.path)) {
|
|
1351
|
+
send(
|
|
1352
|
+
JSON.stringify({
|
|
1353
|
+
type: "dir_list_response",
|
|
1354
|
+
requestId: msg.requestId,
|
|
1355
|
+
path: msg.path,
|
|
1356
|
+
entries: [],
|
|
1357
|
+
errorCode: ControlErrorCode.INVALID_PATH,
|
|
1358
|
+
error: "Invalid path: must be absolute and must not contain path traversal"
|
|
1359
|
+
})
|
|
1360
|
+
);
|
|
1361
|
+
serviceLogger.warn({ path: msg.path }, "Rejected dir_list_request: unsafe path");
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
try {
|
|
1365
|
+
const entries = await scanDir(msg.path);
|
|
1366
|
+
send(
|
|
1367
|
+
JSON.stringify({
|
|
1368
|
+
type: "dir_list_response",
|
|
1369
|
+
requestId: msg.requestId,
|
|
1370
|
+
path: msg.path,
|
|
1371
|
+
entries
|
|
1372
|
+
})
|
|
1373
|
+
);
|
|
1374
|
+
serviceLogger.debug({ path: msg.path, count: entries.length }, "Dir list response sent");
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
send(
|
|
1377
|
+
JSON.stringify({
|
|
1378
|
+
type: "dir_list_response",
|
|
1379
|
+
requestId: msg.requestId,
|
|
1380
|
+
path: msg.path,
|
|
1381
|
+
entries: [],
|
|
1382
|
+
errorCode: classifyPathError(err),
|
|
1383
|
+
error: String(err)
|
|
1384
|
+
})
|
|
1385
|
+
);
|
|
1386
|
+
serviceLogger.warn({ path: msg.path, error: String(err) }, "Dir list request failed");
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
async handleDirCreateRequest(msg) {
|
|
1390
|
+
if (!isPathSafe(msg.path)) {
|
|
1391
|
+
send(
|
|
1392
|
+
JSON.stringify({
|
|
1393
|
+
type: "dir_create_response",
|
|
1394
|
+
requestId: msg.requestId,
|
|
1395
|
+
path: msg.path,
|
|
1396
|
+
success: false,
|
|
1397
|
+
errorCode: ControlErrorCode.INVALID_PATH,
|
|
1398
|
+
error: "Invalid path: must be absolute and must not contain path traversal"
|
|
1399
|
+
})
|
|
1400
|
+
);
|
|
1401
|
+
serviceLogger.warn({ path: msg.path }, "Rejected dir_create_request: unsafe path");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
try {
|
|
1405
|
+
await mkdir(msg.path, { recursive: true });
|
|
1406
|
+
send(
|
|
1407
|
+
JSON.stringify({
|
|
1408
|
+
type: "dir_create_response",
|
|
1409
|
+
requestId: msg.requestId,
|
|
1410
|
+
path: msg.path,
|
|
1411
|
+
success: true
|
|
1412
|
+
})
|
|
1413
|
+
);
|
|
1414
|
+
serviceLogger.info({ path: msg.path }, "Directory created");
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
send(
|
|
1417
|
+
JSON.stringify({
|
|
1418
|
+
type: "dir_create_response",
|
|
1419
|
+
requestId: msg.requestId,
|
|
1420
|
+
path: msg.path,
|
|
1421
|
+
success: false,
|
|
1422
|
+
errorCode: classifyPathError(err),
|
|
1423
|
+
error: String(err)
|
|
1424
|
+
})
|
|
1425
|
+
);
|
|
1426
|
+
serviceLogger.warn({ path: msg.path, error: String(err) }, "Dir create failed");
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1429
|
+
async handleSessionHistoryRequest(msg) {
|
|
1430
|
+
try {
|
|
1431
|
+
const sessions = await scanSessionHistory();
|
|
1432
|
+
send(
|
|
1433
|
+
JSON.stringify({
|
|
1434
|
+
type: "session_history_response",
|
|
1435
|
+
requestId: msg.requestId,
|
|
1436
|
+
sessions
|
|
1437
|
+
})
|
|
1438
|
+
);
|
|
1439
|
+
serviceLogger.debug({ count: sessions.length }, "Session history response sent");
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
send(
|
|
1442
|
+
JSON.stringify({
|
|
1443
|
+
type: "session_history_response",
|
|
1444
|
+
requestId: msg.requestId,
|
|
1445
|
+
sessions: []
|
|
1446
|
+
})
|
|
1447
|
+
);
|
|
1448
|
+
serviceLogger.warn({ error: String(err) }, "Session history scan failed");
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
async handleSessionResourcesRequest(msg) {
|
|
1452
|
+
getResources(msg.sessionId).fileTreeWorkDir = msg.workDir;
|
|
1453
|
+
scheduleCommandRefresh(msg.sessionId, msg.workDir);
|
|
1454
|
+
const [commandsResult, groupsResult] = await Promise.allSettled([
|
|
1455
|
+
discoverCommands(msg.workDir),
|
|
1456
|
+
getFileTree(msg.workDir)
|
|
1457
|
+
]);
|
|
1458
|
+
const commands = commandsResult.status === "fulfilled" ? commandsResult.value : [];
|
|
1459
|
+
const groups = groupsResult.status === "fulfilled" ? groupsResult.value : [];
|
|
1460
|
+
const failedReason = commandsResult.status === "rejected" ? commandsResult.reason : groupsResult.status === "rejected" ? groupsResult.reason : void 0;
|
|
1461
|
+
send(
|
|
1462
|
+
JSON.stringify({
|
|
1463
|
+
type: "session_resources_response",
|
|
1464
|
+
requestId: msg.requestId,
|
|
1465
|
+
sessionId: msg.sessionId,
|
|
1466
|
+
commands,
|
|
1467
|
+
groups,
|
|
1468
|
+
...failedReason ? {
|
|
1469
|
+
errorCode: classifyPathError(failedReason),
|
|
1470
|
+
error: String(failedReason)
|
|
1471
|
+
} : {}
|
|
1472
|
+
})
|
|
1473
|
+
);
|
|
1474
|
+
serviceLogger.info(
|
|
1475
|
+
{ sessionId: msg.sessionId, commandCount: commands.length, groupCount: groups.length },
|
|
1476
|
+
"Session resources snapshot sent"
|
|
1477
|
+
);
|
|
1478
|
+
},
|
|
1479
|
+
async pushCommandList(sessionId, workDir) {
|
|
1480
|
+
try {
|
|
1481
|
+
const commands = await discoverCommands(workDir);
|
|
1482
|
+
send(
|
|
1483
|
+
JSON.stringify({
|
|
1484
|
+
type: "command_list_push",
|
|
1485
|
+
commands
|
|
1486
|
+
})
|
|
1487
|
+
);
|
|
1488
|
+
serviceLogger.info({ sessionId, count: commands.length, workDir }, "Command list pushed");
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
serviceLogger.warn({ sessionId, error: String(err) }, "Command discovery failed");
|
|
1491
|
+
}
|
|
1492
|
+
scheduleCommandRefresh(sessionId, workDir);
|
|
1493
|
+
},
|
|
1494
|
+
async pushFileTree(sessionId, workDir) {
|
|
1495
|
+
const resources = getResources(sessionId);
|
|
1496
|
+
resources.fileTreeWorkDir = workDir;
|
|
1497
|
+
try {
|
|
1498
|
+
const groups = await getFileTree(workDir);
|
|
1499
|
+
send(
|
|
1500
|
+
JSON.stringify({
|
|
1501
|
+
type: "file_tree_push",
|
|
1502
|
+
groups
|
|
1503
|
+
})
|
|
1504
|
+
);
|
|
1505
|
+
serviceLogger.debug(
|
|
1506
|
+
{ sessionId, path: workDir, groupCount: groups.length },
|
|
1507
|
+
"File tree pushed"
|
|
1508
|
+
);
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
serviceLogger.warn({ sessionId, error: String(err) }, "File tree push failed");
|
|
1511
|
+
}
|
|
1512
|
+
},
|
|
1513
|
+
// relay 重连时同步 session 列表并重新推送控制数据
|
|
1514
|
+
async reinitializeOnReconnect() {
|
|
1515
|
+
const activeSessions = sessionManager.listSessions().filter((s) => s.state !== "terminated");
|
|
1516
|
+
if (activeSessions.length > 0) {
|
|
1517
|
+
send(
|
|
1518
|
+
JSON.stringify({
|
|
1519
|
+
type: "session_sync",
|
|
1520
|
+
sessions: activeSessions.map((s) => ({
|
|
1521
|
+
id: s.id,
|
|
1522
|
+
mode: s.mode,
|
|
1523
|
+
provider: s.provider,
|
|
1524
|
+
...s.ptyOwner !== void 0 ? { ptyOwner: s.ptyOwner } : {},
|
|
1525
|
+
state: s.state
|
|
1526
|
+
}))
|
|
1527
|
+
})
|
|
1528
|
+
);
|
|
1529
|
+
serviceLogger.info({ count: activeSessions.length }, "Session list synced to relay");
|
|
1530
|
+
}
|
|
1531
|
+
for (const session of activeSessions) {
|
|
1532
|
+
const resources = sessionResources.get(session.id);
|
|
1533
|
+
const workDir = resources?.fileTreeWorkDir;
|
|
1534
|
+
if (workDir) {
|
|
1535
|
+
try {
|
|
1536
|
+
const commands = await discoverCommands(workDir);
|
|
1537
|
+
send(
|
|
1538
|
+
JSON.stringify({
|
|
1539
|
+
type: "command_list_push",
|
|
1540
|
+
commands
|
|
1541
|
+
})
|
|
1542
|
+
);
|
|
1543
|
+
const groups = await getFileTree(workDir);
|
|
1544
|
+
send(
|
|
1545
|
+
JSON.stringify({
|
|
1546
|
+
type: "file_tree_push",
|
|
1547
|
+
groups
|
|
1548
|
+
})
|
|
1549
|
+
);
|
|
1550
|
+
serviceLogger.info(
|
|
1551
|
+
{ sessionId: session.id },
|
|
1552
|
+
"Reinitialized control data after reconnect"
|
|
1553
|
+
);
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
serviceLogger.warn(
|
|
1556
|
+
{ sessionId: session.id, error: String(err) },
|
|
1557
|
+
"Reinitialize failed"
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
},
|
|
1563
|
+
cleanup(sessionId) {
|
|
1564
|
+
const resources = sessionResources.get(sessionId);
|
|
1565
|
+
if (resources) {
|
|
1566
|
+
if (resources.commandRefreshTimer) {
|
|
1567
|
+
clearInterval(resources.commandRefreshTimer);
|
|
1568
|
+
}
|
|
1569
|
+
sessionResources.delete(sessionId);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/serve/worker-registry.ts
|
|
1576
|
+
import { connect } from "net";
|
|
1577
|
+
import { unlinkSync, existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
|
|
1578
|
+
var WorkerRegistry = class {
|
|
1579
|
+
constructor(deps) {
|
|
1580
|
+
this.deps = deps;
|
|
1581
|
+
deps.relayConnection.on("envelope_dropped", (raw) => this.onEnvelopeDropped(raw));
|
|
1582
|
+
}
|
|
1583
|
+
deps;
|
|
1584
|
+
sockets = /* @__PURE__ */ new Map();
|
|
1585
|
+
children = /* @__PURE__ */ new Map();
|
|
1586
|
+
// 记录哪些 session 是 spawn 时带 --stream-delta 的;forwardEvent 据此决定是否跳过 aggregated 去重
|
|
1587
|
+
streamDeltaSessions = /* @__PURE__ */ new Set();
|
|
1588
|
+
onEnvelopeDropped(raw) {
|
|
1589
|
+
let parsed;
|
|
1590
|
+
try {
|
|
1591
|
+
parsed = JSON.parse(raw);
|
|
1592
|
+
} catch {
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (!parsed || typeof parsed !== "object" || parsed.type !== "tool_use_request") {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const envelope = parsed;
|
|
1599
|
+
const sessionId = typeof envelope.sessionId === "string" ? envelope.sessionId : null;
|
|
1600
|
+
const requestId = envelope.payload && typeof envelope.payload.toolId === "string" ? envelope.payload.toolId : null;
|
|
1601
|
+
if (!sessionId || !requestId) return;
|
|
1602
|
+
if (!this.deps.permissionBroker.resolve(requestId, {
|
|
1603
|
+
behavior: "deny",
|
|
1604
|
+
message: "Approval request was dropped due to relay queue overflow."
|
|
1605
|
+
})) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
serviceLogger.warn(
|
|
1609
|
+
{ sessionId, requestId },
|
|
1610
|
+
"Tool approval request lost to relay queue overflow, denying worker"
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
spawn(sessionId, options) {
|
|
1614
|
+
const paths = sessionPaths(sessionId);
|
|
1615
|
+
const args = [sessionId, paths.workerSock];
|
|
1616
|
+
if (options?.cwd) args.push("--cwd", options.cwd);
|
|
1617
|
+
if (options?.resumeSessionId) args.push("--resume", options.resumeSessionId);
|
|
1618
|
+
args.push("--permission-mode", options?.permissionMode ?? "default");
|
|
1619
|
+
if (options?.streamDelta) {
|
|
1620
|
+
args.push("--stream-delta");
|
|
1621
|
+
this.streamDeltaSessions.add(sessionId);
|
|
1622
|
+
}
|
|
1623
|
+
if (options?.hook) {
|
|
1624
|
+
args.push(
|
|
1625
|
+
"--hook-provider",
|
|
1626
|
+
options.hook.provider,
|
|
1627
|
+
"--hook-url",
|
|
1628
|
+
options.hook.hookUrl,
|
|
1629
|
+
"--hook-marker",
|
|
1630
|
+
options.hook.marker
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
args.push("--");
|
|
1634
|
+
const providerEnv = this.deps.getProviderEnv();
|
|
1635
|
+
const child = spawnScript(new URL("../session-worker", import.meta.url), args, {
|
|
1636
|
+
logger: serviceLogger,
|
|
1637
|
+
env: options?.hook ? { ...providerEnv, DEV_ANYWHERE_HOOK_TOKEN: options.hook.token } : providerEnv
|
|
1638
|
+
});
|
|
1639
|
+
const workerPid = child.pid;
|
|
1640
|
+
this.children.set(sessionId, child);
|
|
1641
|
+
serviceLogger.info(
|
|
1642
|
+
{ sessionId, workerPid, cwd: options?.cwd, resume: options?.resumeSessionId },
|
|
1643
|
+
"Worker process spawned"
|
|
1644
|
+
);
|
|
1645
|
+
return workerPid;
|
|
1646
|
+
}
|
|
1647
|
+
connect(sessionId, sockPath) {
|
|
1648
|
+
return new Promise((resolve) => {
|
|
1649
|
+
const sock = connect(sockPath);
|
|
1650
|
+
sock.on("connect", () => {
|
|
1651
|
+
this.sockets.set(sessionId, sock);
|
|
1652
|
+
createWorkerReader(sock, (msg) => this.handleWorkerMessage(sessionId, msg));
|
|
1653
|
+
sock.on("close", () => this.onDisconnect(sessionId));
|
|
1654
|
+
sock.on("error", () => this.onDisconnect(sessionId));
|
|
1655
|
+
resolve(sock);
|
|
1656
|
+
});
|
|
1657
|
+
sock.on("error", () => resolve(null));
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
// 枚举 DATA_DIR 下所有 session 目录,尝试连接存活的 worker.sock;失败则清理 stale socket。
|
|
1661
|
+
async reconnectAll() {
|
|
1662
|
+
if (!existsSync4(DATA_DIR)) return;
|
|
1663
|
+
const dirs = readdirSync2(DATA_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
1664
|
+
for (const dir of dirs) {
|
|
1665
|
+
const sessionId = dir.name;
|
|
1666
|
+
const paths = sessionPaths(sessionId);
|
|
1667
|
+
if (!existsSync4(paths.workerSock)) continue;
|
|
1668
|
+
const sock = await this.connect(sessionId, paths.workerSock);
|
|
1669
|
+
if (sock) {
|
|
1670
|
+
if (!this.deps.sessionManager.getSession(sessionId)) {
|
|
1671
|
+
serviceLogger.warn(
|
|
1672
|
+
{ sessionId },
|
|
1673
|
+
"Orphaned worker found without session data, terminating"
|
|
1674
|
+
);
|
|
1675
|
+
sock.end();
|
|
1676
|
+
this.sockets.delete(sessionId);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
serviceLogger.info({ sessionId }, "Reconnected to existing worker");
|
|
1680
|
+
} else {
|
|
1681
|
+
try {
|
|
1682
|
+
unlinkSync(paths.workerSock);
|
|
1683
|
+
} catch {
|
|
1684
|
+
}
|
|
1685
|
+
serviceLogger.info({ sessionId }, "Cleaned up stale worker socket");
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
has(sessionId) {
|
|
1690
|
+
return this.sockets.has(sessionId);
|
|
1691
|
+
}
|
|
1692
|
+
delete(sessionId) {
|
|
1693
|
+
this.children.delete(sessionId);
|
|
1694
|
+
this.sockets.delete(sessionId);
|
|
1695
|
+
this.streamDeltaSessions.delete(sessionId);
|
|
1696
|
+
}
|
|
1697
|
+
terminateProcess(sessionId, signal = "SIGTERM") {
|
|
1698
|
+
const child = this.children.get(sessionId);
|
|
1699
|
+
const sock = this.sockets.get(sessionId);
|
|
1700
|
+
sock?.destroy();
|
|
1701
|
+
this.sockets.delete(sessionId);
|
|
1702
|
+
this.streamDeltaSessions.delete(sessionId);
|
|
1703
|
+
this.children.delete(sessionId);
|
|
1704
|
+
if (!child || child.killed) return false;
|
|
1705
|
+
return child.kill(signal);
|
|
1706
|
+
}
|
|
1707
|
+
// 向指定 session 的 worker 写 WorkerMessage;socket 缺失或不可写返回 false 由 caller 决定日志。
|
|
1708
|
+
send(sessionId, msg) {
|
|
1709
|
+
const sock = this.sockets.get(sessionId);
|
|
1710
|
+
if (!sock?.writable) return false;
|
|
1711
|
+
sock.write(serializeWorkerMsg(msg));
|
|
1712
|
+
return true;
|
|
1713
|
+
}
|
|
1714
|
+
destroyAll() {
|
|
1715
|
+
for (const [, ws] of this.sockets) {
|
|
1716
|
+
ws.destroy();
|
|
1717
|
+
}
|
|
1718
|
+
this.sockets.clear();
|
|
1719
|
+
}
|
|
1720
|
+
handleWorkerMessage(sessionId, msg) {
|
|
1721
|
+
switch (msg.type) {
|
|
1722
|
+
case "worker_ready":
|
|
1723
|
+
serviceLogger.info({ sessionId, pid: msg.pid }, "Worker ready");
|
|
1724
|
+
break;
|
|
1725
|
+
case "worker_event":
|
|
1726
|
+
try {
|
|
1727
|
+
this.forwardEvent(sessionId, msg.seq, msg.event);
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
serviceLogger.debug(
|
|
1730
|
+
{ sessionId, error: String(err) },
|
|
1731
|
+
"Failed to forward event to relay"
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
serviceLogger.debug({ sessionId, eventType: msg.event.type }, "JSON session event");
|
|
1735
|
+
break;
|
|
1736
|
+
case "worker_exit":
|
|
1737
|
+
this.deps.sessionManager.terminateSession(sessionId);
|
|
1738
|
+
this.delete(sessionId);
|
|
1739
|
+
serviceLogger.info({ sessionId, exitCode: msg.code }, "JSON session exited");
|
|
1740
|
+
break;
|
|
1741
|
+
case "worker_approval_request":
|
|
1742
|
+
this.forwardApprovalRequest(sessionId, msg);
|
|
1743
|
+
break;
|
|
1744
|
+
case "worker_claude_session_id":
|
|
1745
|
+
this.deps.sessionManager.setClaudeSessionId(sessionId, msg.sessionId);
|
|
1746
|
+
serviceLogger.info(
|
|
1747
|
+
{ sessionId, claudeSessionId: msg.sessionId },
|
|
1748
|
+
"Claude session ID captured"
|
|
1749
|
+
);
|
|
1750
|
+
break;
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
// worker 连接断开或异常时的统一清理入口。仅记录一份,不再区分 close vs error 语义。
|
|
1754
|
+
onDisconnect(sessionId) {
|
|
1755
|
+
this.sockets.delete(sessionId);
|
|
1756
|
+
this.deps.permissionBroker.cleanupSession(sessionId, "Worker disconnected");
|
|
1757
|
+
}
|
|
1758
|
+
// 对齐 Claude CLI stream-json 输出,按 type 分发:
|
|
1759
|
+
// stream_event.content_block_delta → 增量 text/thinking envelope(仅 streamDelta 会话产生)
|
|
1760
|
+
// assistant.content[].text → assistant_message envelope(streamDelta 下跳过,避免重复)
|
|
1761
|
+
// assistant.content[].thinking → thinking envelope(streamDelta 下跳过)
|
|
1762
|
+
// assistant.content[].tool_use → assistant_tool_use envelope
|
|
1763
|
+
// user.content[].tool_result → tool_result envelope
|
|
1764
|
+
// result → turn_result control + 会话状态回 IDLE
|
|
1765
|
+
// system/rate_limit_event/其他 → 静默忽略
|
|
1766
|
+
// schema 未识别的 event/block 以 warn 暴露,作为 Claude CLI 协议变化的 runtime canary。
|
|
1767
|
+
forwardEvent(sessionId, seq, event) {
|
|
1768
|
+
const relay = this.deps.relayConnection;
|
|
1769
|
+
const parsed = StreamJsonEventSchema.safeParse(event);
|
|
1770
|
+
if (!parsed.success) {
|
|
1771
|
+
const rawType = typeof event.type === "string" ? event.type : "<missing>";
|
|
1772
|
+
if (IGNORED_EVENT_TYPES.has(rawType)) {
|
|
1773
|
+
serviceLogger.debug({ sessionId, type: rawType }, "Dropped ignored stream-json event");
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
serviceLogger.warn(
|
|
1777
|
+
{ sessionId, type: rawType, issues: parsed.error.issues.slice(0, 3) },
|
|
1778
|
+
"Unknown stream-json event type; Claude CLI schema may have changed"
|
|
1779
|
+
);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const ev = parsed.data;
|
|
1783
|
+
const isStreamDeltaSession = this.streamDeltaSessions.has(sessionId);
|
|
1784
|
+
if (ev.type === "stream_event") {
|
|
1785
|
+
const delta = ContentBlockDeltaSchema.safeParse(ev.event);
|
|
1786
|
+
if (!delta.success) return;
|
|
1787
|
+
const d = delta.data.delta;
|
|
1788
|
+
if (d.type === "text_delta" && d.text) {
|
|
1789
|
+
relay.sendEnvelope(
|
|
1790
|
+
buildMessage(
|
|
1791
|
+
"assistant_message",
|
|
1792
|
+
sessionId,
|
|
1793
|
+
seq,
|
|
1794
|
+
{ text: d.text, isPartial: true },
|
|
1795
|
+
"proxy"
|
|
1796
|
+
)
|
|
1797
|
+
);
|
|
1798
|
+
} else if (d.type === "thinking_delta" && d.thinking) {
|
|
1799
|
+
relay.sendEnvelope(buildMessage("thinking", sessionId, seq, { text: d.thinking }, "proxy"));
|
|
1800
|
+
}
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (ev.type === "assistant") {
|
|
1804
|
+
for (const raw of ev.message.content) {
|
|
1805
|
+
const blockParse = KnownContentBlockSchema.safeParse(raw);
|
|
1806
|
+
if (!blockParse.success) {
|
|
1807
|
+
const rawType = raw && typeof raw === "object" ? raw.type : void 0;
|
|
1808
|
+
serviceLogger.warn(
|
|
1809
|
+
{ sessionId, seq, blockType: rawType ?? "<missing>" },
|
|
1810
|
+
"Unknown assistant content block; Claude CLI schema may have changed"
|
|
1811
|
+
);
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
const block = blockParse.data;
|
|
1815
|
+
if (block.type === "text") {
|
|
1816
|
+
if (!isStreamDeltaSession && block.text) {
|
|
1817
|
+
relay.sendEnvelope(
|
|
1818
|
+
buildMessage(
|
|
1819
|
+
"assistant_message",
|
|
1820
|
+
sessionId,
|
|
1821
|
+
seq,
|
|
1822
|
+
{ text: block.text, isPartial: true },
|
|
1823
|
+
"proxy"
|
|
1824
|
+
)
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
} else if (block.type === "thinking") {
|
|
1828
|
+
if (!isStreamDeltaSession && block.thinking) {
|
|
1829
|
+
relay.sendEnvelope(
|
|
1830
|
+
buildMessage("thinking", sessionId, seq, { text: block.thinking }, "proxy")
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
} else if (block.type === "tool_use") {
|
|
1834
|
+
relay.sendEnvelope(
|
|
1835
|
+
buildMessage(
|
|
1836
|
+
"assistant_tool_use",
|
|
1837
|
+
sessionId,
|
|
1838
|
+
seq,
|
|
1839
|
+
{ toolName: block.name, toolId: block.id, parameters: block.input },
|
|
1840
|
+
"proxy"
|
|
1841
|
+
)
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (ev.type === "user") {
|
|
1848
|
+
for (const raw of ev.message.content) {
|
|
1849
|
+
const blockParse = KnownContentBlockSchema.safeParse(raw);
|
|
1850
|
+
if (!blockParse.success) continue;
|
|
1851
|
+
const block = blockParse.data;
|
|
1852
|
+
if (block.type !== "tool_result") continue;
|
|
1853
|
+
relay.sendEnvelope(
|
|
1854
|
+
buildMessage(
|
|
1855
|
+
"tool_result",
|
|
1856
|
+
sessionId,
|
|
1857
|
+
seq,
|
|
1858
|
+
{ toolId: block.tool_use_id, result: block.content, isError: block.is_error ?? false },
|
|
1859
|
+
"proxy"
|
|
1860
|
+
)
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (ev.type === "result") {
|
|
1866
|
+
const resultText = typeof ev.result === "string" ? ev.result : void 0;
|
|
1867
|
+
relay.sendRaw(
|
|
1868
|
+
JSON.stringify({
|
|
1869
|
+
type: "turn_result",
|
|
1870
|
+
sessionId,
|
|
1871
|
+
success: ev.subtype === "success",
|
|
1872
|
+
isError: ev.is_error ?? false,
|
|
1873
|
+
...resultText ? { result: resultText } : {}
|
|
1874
|
+
})
|
|
1875
|
+
);
|
|
1876
|
+
this.deps.jsonObserver.onTurnResult(sessionId);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
forwardApprovalRequest(sessionId, msg) {
|
|
1880
|
+
serviceLogger.info(
|
|
1881
|
+
{ sessionId, toolName: msg.toolName, requestId: msg.requestId },
|
|
1882
|
+
"Tool approval forwarding to relay"
|
|
1883
|
+
);
|
|
1884
|
+
this.deps.jsonObserver.onApprovalRequested(sessionId);
|
|
1885
|
+
try {
|
|
1886
|
+
const approvalSeq = this.deps.nextSeq?.(sessionId) ?? new SeqCounter(sessionId).next();
|
|
1887
|
+
const envelope = buildMessage(
|
|
1888
|
+
"tool_use_request",
|
|
1889
|
+
sessionId,
|
|
1890
|
+
approvalSeq,
|
|
1891
|
+
{
|
|
1892
|
+
toolName: msg.toolName,
|
|
1893
|
+
toolId: msg.requestId,
|
|
1894
|
+
parameters: msg.input
|
|
1895
|
+
},
|
|
1896
|
+
"proxy"
|
|
1897
|
+
);
|
|
1898
|
+
const session = this.deps.sessionManager.getSession(sessionId);
|
|
1899
|
+
const registered = this.deps.permissionBroker.registerWorkerRequest(
|
|
1900
|
+
{
|
|
1901
|
+
requestId: msg.requestId,
|
|
1902
|
+
provider: session?.provider ?? "claude",
|
|
1903
|
+
sessionId,
|
|
1904
|
+
toolName: msg.toolName,
|
|
1905
|
+
input: msg.input
|
|
1906
|
+
},
|
|
1907
|
+
(decision) => {
|
|
1908
|
+
this.send(sessionId, {
|
|
1909
|
+
type: "worker_approval_response",
|
|
1910
|
+
requestId: msg.requestId,
|
|
1911
|
+
behavior: decision.behavior,
|
|
1912
|
+
...decision.message ? { message: decision.message } : {}
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
);
|
|
1916
|
+
if (!registered) return;
|
|
1917
|
+
this.deps.relayConnection.sendEnvelope(envelope);
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
const resolved = this.deps.permissionBroker.resolve(msg.requestId, {
|
|
1920
|
+
behavior: "deny",
|
|
1921
|
+
message: "Failed to forward approval request to relay."
|
|
1922
|
+
});
|
|
1923
|
+
if (!resolved) {
|
|
1924
|
+
this.send(sessionId, {
|
|
1925
|
+
type: "worker_approval_response",
|
|
1926
|
+
requestId: msg.requestId,
|
|
1927
|
+
behavior: "deny",
|
|
1928
|
+
message: "Failed to forward approval request to relay."
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
serviceLogger.warn(
|
|
1932
|
+
{ sessionId, error: String(err) },
|
|
1933
|
+
"Failed to forward tool approval to relay, denying"
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
// src/serve/session-termination.ts
|
|
1940
|
+
function terminateSessionByOwnership(deps, sessionId) {
|
|
1941
|
+
const session = deps.sessionManager.getSession(sessionId);
|
|
1942
|
+
if (session?.mode === "pty" && session.ptyOwner === "local-terminal") {
|
|
1943
|
+
const terminalSocket = deps.terminalSockets.get(sessionId);
|
|
1944
|
+
if (terminalSocket?.writable) {
|
|
1945
|
+
terminalSocket.write(serializeIpc({ type: "pty_detach", sessionId }));
|
|
1946
|
+
}
|
|
1947
|
+
deps.terminalSockets.delete(sessionId);
|
|
1948
|
+
const result = deps.sessionManager.terminateSession(sessionId, {
|
|
1949
|
+
preserveProviderHooks: true
|
|
1950
|
+
});
|
|
1951
|
+
deps.controlHandlers.cleanup(sessionId);
|
|
1952
|
+
deps.agentStatusRegistry.delete(sessionId);
|
|
1953
|
+
serviceLogger.info(
|
|
1954
|
+
{ sessionId, success: result.success },
|
|
1955
|
+
"Local terminal session detached from remote view"
|
|
1956
|
+
);
|
|
1957
|
+
return { success: result.success, action: "detach_local_terminal" };
|
|
1958
|
+
}
|
|
1959
|
+
if (session?.mode === "pty" && session.ptyOwner === "proxy-hosted") {
|
|
1960
|
+
const success = deps.hostedPtyRegistry.terminate(sessionId);
|
|
1961
|
+
serviceLogger.info({ sessionId, success }, "Hosted PTY termination requested");
|
|
1962
|
+
return { success, action: "terminate_hosted_pty" };
|
|
1963
|
+
}
|
|
1964
|
+
if (session?.mode === "json") {
|
|
1965
|
+
deps.workerRegistry.send(sessionId, { type: "worker_stop" });
|
|
1966
|
+
deps.workerRegistry.delete(sessionId);
|
|
1967
|
+
const result = deps.sessionManager.terminateSession(sessionId);
|
|
1968
|
+
deps.controlHandlers.cleanup(sessionId);
|
|
1969
|
+
deps.agentStatusRegistry.delete(sessionId);
|
|
1970
|
+
serviceLogger.info({ sessionId, success: result.success }, "JSON worker session terminated");
|
|
1971
|
+
return { success: result.success, action: "terminate_json_worker" };
|
|
1972
|
+
}
|
|
1973
|
+
const hostedTerminated = deps.hostedPtyRegistry.terminate(sessionId);
|
|
1974
|
+
if (hostedTerminated) {
|
|
1975
|
+
return { success: true, action: "terminate_hosted_pty" };
|
|
1976
|
+
}
|
|
1977
|
+
return { success: false, action: "not_found" };
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// src/serve/pty-input.ts
|
|
1981
|
+
function serializeRawPtyInput(sessionId, data) {
|
|
1982
|
+
return serializeIpc({ type: "pty_input", sessionId, data });
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// src/serve/relay-input-handlers.ts
|
|
1986
|
+
var RelayInputHandlers = class {
|
|
1987
|
+
constructor(deps) {
|
|
1988
|
+
this.deps = deps;
|
|
1989
|
+
}
|
|
1990
|
+
deps;
|
|
1991
|
+
onUserInput(msg) {
|
|
1992
|
+
const sessionId = msg.sessionId;
|
|
1993
|
+
if (!sessionId) return;
|
|
1994
|
+
const session = this.deps.sessionManager.getSession(sessionId);
|
|
1995
|
+
if (!session) {
|
|
1996
|
+
serviceLogger.warn({ sessionId }, "Remote input dropped: session not found");
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
const payload = msg.payload;
|
|
2000
|
+
const text = payload?.text ?? "";
|
|
2001
|
+
if (session.mode === "json") {
|
|
2002
|
+
this.deps.jsonObserver.onTurnStart(sessionId);
|
|
2003
|
+
const sent = this.deps.workerRegistry.send(sessionId, {
|
|
2004
|
+
type: "worker_input",
|
|
2005
|
+
content: text
|
|
2006
|
+
});
|
|
2007
|
+
if (!sent) {
|
|
2008
|
+
serviceLogger.warn({ sessionId }, "Remote input dropped: JSON worker socket not available");
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
serviceLogger.info({ sessionId }, "Remote input forwarded to JSON worker");
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
serviceLogger.warn(
|
|
2015
|
+
{ sessionId, mode: session.mode },
|
|
2016
|
+
"Remote batch input dropped: PTY sessions require remote_input_raw"
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
onRemoteInputRaw(msg) {
|
|
2020
|
+
const sessionId = msg.sessionId;
|
|
2021
|
+
const data = msg.data;
|
|
2022
|
+
if (!sessionId || data === void 0) return;
|
|
2023
|
+
const ts = this.deps.terminalSockets.get(sessionId);
|
|
2024
|
+
if (!ts?.writable && this.deps.hostedPtyRegistry.write(sessionId, data)) {
|
|
2025
|
+
serviceLogger.info(
|
|
2026
|
+
{ sessionId, bytes: data.length },
|
|
2027
|
+
"Raw PTY input forwarded to hosted PTY"
|
|
2028
|
+
);
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
if (!ts?.writable) {
|
|
2032
|
+
serviceLogger.warn({ sessionId }, "Raw PTY input dropped: terminal socket unavailable");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
ts.write(serializeRawPtyInput(sessionId, data));
|
|
2036
|
+
serviceLogger.info({ sessionId, bytes: data.length }, "Raw PTY input forwarded");
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// src/serve/relay-history-handlers.ts
|
|
2041
|
+
var RelayHistoryHandlers = class {
|
|
2042
|
+
constructor(deps) {
|
|
2043
|
+
this.deps = deps;
|
|
2044
|
+
}
|
|
2045
|
+
deps;
|
|
2046
|
+
onSessionMessagesRequest(msg) {
|
|
2047
|
+
const sid = msg.sessionId;
|
|
2048
|
+
if (!sid) return;
|
|
2049
|
+
const requestId = msg.requestId;
|
|
2050
|
+
const session = this.deps.sessionManager.getSession(sid);
|
|
2051
|
+
if (session?.claudeSessionId) {
|
|
2052
|
+
readSessionMessages(session.claudeSessionId).then((messages) => {
|
|
2053
|
+
this.deps.relaySend(
|
|
2054
|
+
JSON.stringify({
|
|
2055
|
+
type: "session_history_messages",
|
|
2056
|
+
requestId,
|
|
2057
|
+
sessionId: sid,
|
|
2058
|
+
messages
|
|
2059
|
+
})
|
|
2060
|
+
);
|
|
2061
|
+
serviceLogger.info(
|
|
2062
|
+
{ sessionId: sid, messageCount: messages.length },
|
|
2063
|
+
"History messages sent on request"
|
|
2064
|
+
);
|
|
2065
|
+
}).catch((err) => {
|
|
2066
|
+
serviceLogger.warn(
|
|
2067
|
+
{ sessionId: sid, error: String(err) },
|
|
2068
|
+
"Failed to read session history messages on request"
|
|
2069
|
+
);
|
|
2070
|
+
this.deps.relaySend(
|
|
2071
|
+
JSON.stringify({
|
|
2072
|
+
type: "session_history_messages",
|
|
2073
|
+
requestId,
|
|
2074
|
+
sessionId: sid,
|
|
2075
|
+
messages: []
|
|
2076
|
+
})
|
|
2077
|
+
);
|
|
2078
|
+
});
|
|
2079
|
+
} else {
|
|
2080
|
+
this.deps.relaySend(
|
|
2081
|
+
JSON.stringify({
|
|
2082
|
+
type: "session_history_messages",
|
|
2083
|
+
requestId,
|
|
2084
|
+
sessionId: sid,
|
|
2085
|
+
messages: []
|
|
2086
|
+
})
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
const approvals = this.deps.permissionBroker.listSession(sid).map((approval) => ({
|
|
2090
|
+
requestId: approval.requestId,
|
|
2091
|
+
toolName: approval.toolName,
|
|
2092
|
+
input: approval.input
|
|
2093
|
+
}));
|
|
2094
|
+
this.deps.relaySend(
|
|
2095
|
+
JSON.stringify({ type: "pending_approvals_push", sessionId: sid, approvals })
|
|
2096
|
+
);
|
|
2097
|
+
serviceLogger.info({ sessionId: sid, count: approvals.length }, "Pending approvals pushed");
|
|
2098
|
+
}
|
|
2099
|
+
};
|
|
2100
|
+
|
|
2101
|
+
// src/serve/relay-permission-handlers.ts
|
|
2102
|
+
var RelayPermissionHandlers = class {
|
|
2103
|
+
constructor(deps) {
|
|
2104
|
+
this.deps = deps;
|
|
2105
|
+
}
|
|
2106
|
+
deps;
|
|
2107
|
+
onToolApprove(msg) {
|
|
2108
|
+
const sessionId = msg.sessionId;
|
|
2109
|
+
const payload = msg.payload;
|
|
2110
|
+
if (!sessionId || !payload?.toolId) return;
|
|
2111
|
+
const pending = this.deps.permissionBroker.get(payload.toolId);
|
|
2112
|
+
if (!pending) {
|
|
2113
|
+
this.pushPermissionDecisionResult(
|
|
2114
|
+
sessionId,
|
|
2115
|
+
payload.toolId,
|
|
2116
|
+
"allow",
|
|
2117
|
+
false,
|
|
2118
|
+
"Permission request is no longer pending."
|
|
2119
|
+
);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (!this.deps.permissionBroker.resolve(payload.toolId, { behavior: "allow" })) {
|
|
2123
|
+
this.pushPermissionDecisionResult(
|
|
2124
|
+
pending.sessionId,
|
|
2125
|
+
payload.toolId,
|
|
2126
|
+
"allow",
|
|
2127
|
+
false,
|
|
2128
|
+
"Permission request is no longer pending."
|
|
2129
|
+
);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
this.deps.hookEventRouter.onPermissionResolved(
|
|
2133
|
+
pending.sessionId,
|
|
2134
|
+
pending.provider,
|
|
2135
|
+
payload.toolId,
|
|
2136
|
+
"allow",
|
|
2137
|
+
{ toolName: pending.toolName, toolInput: pending.input }
|
|
2138
|
+
);
|
|
2139
|
+
if (pending.source === "worker" && payload.whitelistTool) {
|
|
2140
|
+
const toolName = pending.toolName;
|
|
2141
|
+
if (toolName) {
|
|
2142
|
+
const whitelisted = this.deps.workerRegistry.send(pending.sessionId, {
|
|
2143
|
+
type: "worker_whitelist_add",
|
|
2144
|
+
toolName
|
|
2145
|
+
});
|
|
2146
|
+
if (whitelisted) {
|
|
2147
|
+
serviceLogger.info(
|
|
2148
|
+
{ sessionId: pending.sessionId, toolName },
|
|
2149
|
+
"Tool added to session whitelist via relay"
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
this.pushPermissionDecisionResult(pending.sessionId, payload.toolId, "allow", true);
|
|
2155
|
+
serviceLogger.info(
|
|
2156
|
+
{ sessionId, toolId: payload.toolId, whitelistTool: payload.whitelistTool },
|
|
2157
|
+
"Tool approved via relay"
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
onToolDeny(msg) {
|
|
2161
|
+
const sessionId = msg.sessionId;
|
|
2162
|
+
const payload = msg.payload;
|
|
2163
|
+
if (!sessionId || !payload?.toolId) return;
|
|
2164
|
+
const reason = payload.reason ?? "Denied by remote user";
|
|
2165
|
+
const pending = this.deps.permissionBroker.get(payload.toolId);
|
|
2166
|
+
if (!pending) {
|
|
2167
|
+
this.pushPermissionDecisionResult(
|
|
2168
|
+
sessionId,
|
|
2169
|
+
payload.toolId,
|
|
2170
|
+
"deny",
|
|
2171
|
+
false,
|
|
2172
|
+
"Permission request is no longer pending."
|
|
2173
|
+
);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
if (!this.deps.permissionBroker.resolve(payload.toolId, {
|
|
2177
|
+
behavior: "deny",
|
|
2178
|
+
message: reason
|
|
2179
|
+
})) {
|
|
2180
|
+
this.pushPermissionDecisionResult(
|
|
2181
|
+
pending.sessionId,
|
|
2182
|
+
payload.toolId,
|
|
2183
|
+
"deny",
|
|
2184
|
+
false,
|
|
2185
|
+
"Permission request is no longer pending."
|
|
2186
|
+
);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
this.deps.hookEventRouter.onPermissionResolved(
|
|
2190
|
+
pending.sessionId,
|
|
2191
|
+
pending.provider,
|
|
2192
|
+
payload.toolId,
|
|
2193
|
+
"deny",
|
|
2194
|
+
{ toolName: pending.toolName, toolInput: pending.input }
|
|
2195
|
+
);
|
|
2196
|
+
this.pushPermissionDecisionResult(pending.sessionId, payload.toolId, "deny", true, reason);
|
|
2197
|
+
serviceLogger.info({ sessionId, toolId: payload.toolId }, "Tool denied via relay");
|
|
2198
|
+
}
|
|
2199
|
+
onPermissionRequestDelivered(msg) {
|
|
2200
|
+
const sid = msg.sessionId;
|
|
2201
|
+
const requestId = msg.requestId;
|
|
2202
|
+
if (!sid || !requestId) return;
|
|
2203
|
+
const marked = this.deps.permissionBroker.markDelivered(requestId);
|
|
2204
|
+
serviceLogger.info({ sessionId: sid, requestId, marked }, "Permission request delivered");
|
|
2205
|
+
}
|
|
2206
|
+
pushPermissionDecisionResult(sessionId, requestId, outcome, delivered, message) {
|
|
2207
|
+
this.deps.relaySend(
|
|
2208
|
+
JSON.stringify({
|
|
2209
|
+
type: "permission_decision_result",
|
|
2210
|
+
sessionId,
|
|
2211
|
+
requestId,
|
|
2212
|
+
outcome,
|
|
2213
|
+
delivered,
|
|
2214
|
+
...message ? { message } : {}
|
|
2215
|
+
})
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
// src/serve/relay-resource-handlers.ts
|
|
2221
|
+
import { homedir as homedir4 } from "os";
|
|
2222
|
+
import { accessSync, constants, statSync } from "fs";
|
|
2223
|
+
function errorMessage(err) {
|
|
2224
|
+
return err instanceof Error ? err.message : String(err);
|
|
2225
|
+
}
|
|
2226
|
+
function validateExecutablePath(path) {
|
|
2227
|
+
const normalized = path.trim();
|
|
2228
|
+
if (!normalized.startsWith("/")) throw new Error("CLI \u8DEF\u5F84\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84");
|
|
2229
|
+
const stat2 = statSync(normalized);
|
|
2230
|
+
if (!stat2.isFile()) throw new Error("CLI \u8DEF\u5F84\u4E0D\u662F\u53EF\u6267\u884C\u6587\u4EF6");
|
|
2231
|
+
accessSync(normalized, constants.X_OK);
|
|
2232
|
+
return normalized;
|
|
2233
|
+
}
|
|
2234
|
+
var RelayResourceHandlers = class {
|
|
2235
|
+
constructor(deps) {
|
|
2236
|
+
this.deps = deps;
|
|
2237
|
+
}
|
|
2238
|
+
deps;
|
|
2239
|
+
onProxyInfoRequest(msg) {
|
|
2240
|
+
this.deps.relaySend(
|
|
2241
|
+
JSON.stringify({
|
|
2242
|
+
type: "proxy_info",
|
|
2243
|
+
requestId: msg.requestId,
|
|
2244
|
+
homePath: homedir4() || "/",
|
|
2245
|
+
agentCli: detectAgentCliStatus(this.deps.getProviderEnv(), {
|
|
2246
|
+
suggestions: this.deps.getAgentCliSuggestions()
|
|
2247
|
+
})
|
|
2248
|
+
})
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
onAgentCliConfigUpdate(msg) {
|
|
2252
|
+
const requestId = msg.requestId;
|
|
2253
|
+
const provider = msg.provider;
|
|
2254
|
+
const rawPath = msg.path;
|
|
2255
|
+
if (provider !== "claude" && provider !== "codex") {
|
|
2256
|
+
this.deps.relaySend(
|
|
2257
|
+
JSON.stringify({
|
|
2258
|
+
type: "agent_cli_config_update_response",
|
|
2259
|
+
requestId,
|
|
2260
|
+
provider: "claude",
|
|
2261
|
+
errorCode: ControlErrorCode.PROVIDER_UNSUPPORTED,
|
|
2262
|
+
error: "Unsupported Agent CLI."
|
|
2263
|
+
})
|
|
2264
|
+
);
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
try {
|
|
2268
|
+
const path = validateExecutablePath(rawPath ?? "");
|
|
2269
|
+
saveAgentCliPath(provider, path, { envName: this.deps.envName });
|
|
2270
|
+
this.deps.setAgentCliPath(provider, path);
|
|
2271
|
+
const agentCli = detectAgentCliStatus(this.deps.getProviderEnv(), {
|
|
2272
|
+
suggestions: this.deps.getAgentCliSuggestions()
|
|
2273
|
+
});
|
|
2274
|
+
this.deps.relaySend(
|
|
2275
|
+
JSON.stringify({
|
|
2276
|
+
type: "agent_cli_config_update_response",
|
|
2277
|
+
requestId,
|
|
2278
|
+
provider,
|
|
2279
|
+
agentCli
|
|
2280
|
+
})
|
|
2281
|
+
);
|
|
2282
|
+
serviceLogger.info({ provider, path }, "Agent CLI path updated");
|
|
2283
|
+
} catch (err) {
|
|
2284
|
+
const error = errorMessage(err);
|
|
2285
|
+
this.deps.relaySend(
|
|
2286
|
+
JSON.stringify({
|
|
2287
|
+
type: "agent_cli_config_update_response",
|
|
2288
|
+
requestId,
|
|
2289
|
+
provider,
|
|
2290
|
+
errorCode: ControlErrorCode.INVALID_PATH,
|
|
2291
|
+
error
|
|
2292
|
+
})
|
|
2293
|
+
);
|
|
2294
|
+
serviceLogger.warn({ provider, path: rawPath, error }, "Agent CLI path update rejected");
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
onDirListRequest(msg) {
|
|
2298
|
+
this.deps.controlHandlers.handleDirListRequest({
|
|
2299
|
+
path: msg.path ?? "",
|
|
2300
|
+
requestId: msg.requestId
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
onDirCreateRequest(msg) {
|
|
2304
|
+
this.deps.controlHandlers.handleDirCreateRequest({
|
|
2305
|
+
path: msg.path ?? "",
|
|
2306
|
+
requestId: msg.requestId
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
onSessionResourcesRequest(msg) {
|
|
2310
|
+
const sid = msg.sessionId;
|
|
2311
|
+
if (!sid) return;
|
|
2312
|
+
const session = this.deps.sessionManager.getSession(sid);
|
|
2313
|
+
if (!session?.cwd) {
|
|
2314
|
+
serviceLogger.warn({ sessionId: sid }, "Session resources request: no cwd available");
|
|
2315
|
+
this.deps.relaySend(
|
|
2316
|
+
JSON.stringify({
|
|
2317
|
+
type: "session_resources_response",
|
|
2318
|
+
requestId: msg.requestId,
|
|
2319
|
+
sessionId: sid,
|
|
2320
|
+
commands: [],
|
|
2321
|
+
groups: [],
|
|
2322
|
+
errorCode: ControlErrorCode.SESSION_NOT_FOUND,
|
|
2323
|
+
error: "Session not found or cwd unavailable"
|
|
2324
|
+
})
|
|
2325
|
+
);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
this.deps.controlHandlers.handleSessionResourcesRequest({
|
|
2329
|
+
sessionId: sid,
|
|
2330
|
+
requestId: msg.requestId,
|
|
2331
|
+
workDir: session.cwd
|
|
2332
|
+
});
|
|
2333
|
+
serviceLogger.info({ sessionId: sid, cwd: session.cwd }, "Session resources requested");
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
|
|
2337
|
+
// src/serve/relay-session-create-handler.ts
|
|
2338
|
+
import { rmSync, statSync as statSync2 } from "fs";
|
|
2339
|
+
import { isAbsolute as isAbsolute3 } from "path";
|
|
2340
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
2341
|
+
|
|
2342
|
+
// src/serve/hosted-pty-registry.ts
|
|
2343
|
+
import * as pty from "node-pty";
|
|
2344
|
+
import pkg from "@xterm/headless";
|
|
2345
|
+
import { SerializeAddon } from "@xterm/addon-serialize";
|
|
2346
|
+
var { Terminal: HeadlessTerminal } = pkg;
|
|
2347
|
+
var DEFAULT_COLS = 80;
|
|
2348
|
+
var DEFAULT_ROWS = 24;
|
|
2349
|
+
var IDLE_CHECK_INTERVAL_MS = 3e3;
|
|
2350
|
+
var IDLE_THRESHOLD_MS = 3e3;
|
|
2351
|
+
var PROVIDERS = {
|
|
2352
|
+
claude: CLAUDE_PROVIDER,
|
|
2353
|
+
codex: CODEX_PROVIDER
|
|
2354
|
+
};
|
|
2355
|
+
var HOSTED_PTY_TERM = "xterm-256color";
|
|
2356
|
+
var HOSTED_PTY_COLORTERM = "truecolor";
|
|
2357
|
+
function buildHostedPtyArgs(provider, resumeSessionId) {
|
|
2358
|
+
if (!resumeSessionId) return [];
|
|
2359
|
+
return provider === "codex" ? ["resume", resumeSessionId] : ["--resume", resumeSessionId];
|
|
2360
|
+
}
|
|
2361
|
+
function normalizeHostedPtyEnv(env) {
|
|
2362
|
+
const normalized = {};
|
|
2363
|
+
for (const [key, value] of Object.entries(env)) {
|
|
2364
|
+
if (value === void 0) continue;
|
|
2365
|
+
normalized[key] = value;
|
|
2366
|
+
}
|
|
2367
|
+
delete normalized.NO_COLOR;
|
|
2368
|
+
if (normalized.CLICOLOR === "0") {
|
|
2369
|
+
delete normalized.CLICOLOR;
|
|
2370
|
+
}
|
|
2371
|
+
normalized.TERM = HOSTED_PTY_TERM;
|
|
2372
|
+
normalized.COLORTERM = HOSTED_PTY_COLORTERM;
|
|
2373
|
+
normalized.CLICOLOR = "1";
|
|
2374
|
+
return normalized;
|
|
2375
|
+
}
|
|
2376
|
+
var HostedPtyRegistry = class {
|
|
2377
|
+
constructor(deps) {
|
|
2378
|
+
this.deps = deps;
|
|
2379
|
+
}
|
|
2380
|
+
deps;
|
|
2381
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2382
|
+
start(options) {
|
|
2383
|
+
const provider = PROVIDERS[options.provider];
|
|
2384
|
+
const cols = options.cols ?? DEFAULT_COLS;
|
|
2385
|
+
const rows = options.rows ?? DEFAULT_ROWS;
|
|
2386
|
+
const command = provider.buildTerminalCommand(
|
|
2387
|
+
{ args: options.args, permissionMode: options.permissionMode, hook: options.hook },
|
|
2388
|
+
this.deps.getProviderEnv()
|
|
2389
|
+
);
|
|
2390
|
+
const env = normalizeHostedPtyEnv(command.env);
|
|
2391
|
+
const child = pty.spawn(command.command, command.args, {
|
|
2392
|
+
name: HOSTED_PTY_TERM,
|
|
2393
|
+
cols,
|
|
2394
|
+
rows,
|
|
2395
|
+
cwd: options.cwd,
|
|
2396
|
+
env
|
|
2397
|
+
});
|
|
2398
|
+
const terminal = new HeadlessTerminal({ cols, rows, scrollback: 5e3, allowProposedApi: true });
|
|
2399
|
+
const serializeAddon = new SerializeAddon();
|
|
2400
|
+
terminal.loadAddon(serializeAddon);
|
|
2401
|
+
void import("@xterm/addon-unicode-graphemes").then(({ UnicodeGraphemesAddon }) => terminal.loadAddon(new UnicodeGraphemesAddon())).catch((err) => {
|
|
2402
|
+
serviceLogger.warn(
|
|
2403
|
+
{ sessionId: options.sessionId, error: String(err) },
|
|
2404
|
+
"Unicode addon unavailable"
|
|
2405
|
+
);
|
|
2406
|
+
});
|
|
2407
|
+
const hosted = {
|
|
2408
|
+
child,
|
|
2409
|
+
terminal,
|
|
2410
|
+
serializeAddon,
|
|
2411
|
+
idleTimer: setInterval(() => this.checkIdle(options.sessionId), IDLE_CHECK_INTERVAL_MS),
|
|
2412
|
+
lastOutputTime: 0,
|
|
2413
|
+
currentState: "turn_complete",
|
|
2414
|
+
outputSeq: 0
|
|
2415
|
+
};
|
|
2416
|
+
this.sessions.set(options.sessionId, hosted);
|
|
2417
|
+
child.onData((data) => this.handleData(options.sessionId, data));
|
|
2418
|
+
child.onExit(({ exitCode, signal }) => {
|
|
2419
|
+
const code = signal ? 128 + signal : exitCode;
|
|
2420
|
+
serviceLogger.info({ sessionId: options.sessionId, code }, "Hosted PTY exited");
|
|
2421
|
+
this.close(options.sessionId, { kill: false, notify: true });
|
|
2422
|
+
});
|
|
2423
|
+
serviceLogger.info(
|
|
2424
|
+
{
|
|
2425
|
+
sessionId: options.sessionId,
|
|
2426
|
+
provider: options.provider,
|
|
2427
|
+
command: command.command,
|
|
2428
|
+
pid: child.pid,
|
|
2429
|
+
cwd: options.cwd,
|
|
2430
|
+
cols,
|
|
2431
|
+
rows
|
|
2432
|
+
},
|
|
2433
|
+
"Hosted PTY started"
|
|
2434
|
+
);
|
|
2435
|
+
return child.pid;
|
|
2436
|
+
}
|
|
2437
|
+
has(sessionId) {
|
|
2438
|
+
return this.sessions.has(sessionId);
|
|
2439
|
+
}
|
|
2440
|
+
write(sessionId, data) {
|
|
2441
|
+
const hosted = this.sessions.get(sessionId);
|
|
2442
|
+
if (!hosted) return false;
|
|
2443
|
+
hosted.child.write(data);
|
|
2444
|
+
return true;
|
|
2445
|
+
}
|
|
2446
|
+
resize(sessionId, cols, rows) {
|
|
2447
|
+
const hosted = this.sessions.get(sessionId);
|
|
2448
|
+
if (!hosted) return false;
|
|
2449
|
+
hosted.child.resize(cols, rows);
|
|
2450
|
+
hosted.terminal.resize(cols, rows);
|
|
2451
|
+
this.deps.relayConnection.sendRaw(
|
|
2452
|
+
JSON.stringify({ type: "terminal_resize", sessionId, cols, rows })
|
|
2453
|
+
);
|
|
2454
|
+
serviceLogger.info({ sessionId, cols, rows }, "Hosted PTY resized");
|
|
2455
|
+
return true;
|
|
2456
|
+
}
|
|
2457
|
+
snapshot(sessionId, requestId) {
|
|
2458
|
+
const hosted = this.sessions.get(sessionId);
|
|
2459
|
+
if (!hosted) return false;
|
|
2460
|
+
const data = hosted.serializeAddon.serialize();
|
|
2461
|
+
this.deps.relayConnection.sendRaw(
|
|
2462
|
+
JSON.stringify({
|
|
2463
|
+
type: "session_snapshot",
|
|
2464
|
+
sessionId,
|
|
2465
|
+
cols: hosted.terminal.cols,
|
|
2466
|
+
rows: hosted.terminal.rows,
|
|
2467
|
+
data,
|
|
2468
|
+
outputSeq: hosted.outputSeq,
|
|
2469
|
+
requestId
|
|
2470
|
+
})
|
|
2471
|
+
);
|
|
2472
|
+
serviceLogger.info(
|
|
2473
|
+
{ sessionId, cols: hosted.terminal.cols, rows: hosted.terminal.rows, bytes: data.length },
|
|
2474
|
+
"Hosted PTY snapshot sent"
|
|
2475
|
+
);
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
terminate(sessionId) {
|
|
2479
|
+
return this.close(sessionId, { kill: true, notify: true });
|
|
2480
|
+
}
|
|
2481
|
+
destroyAll() {
|
|
2482
|
+
for (const sessionId of Array.from(this.sessions.keys())) {
|
|
2483
|
+
this.close(sessionId, { kill: true, notify: false });
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
handleData(sessionId, data) {
|
|
2487
|
+
const hosted = this.sessions.get(sessionId);
|
|
2488
|
+
if (!hosted) return;
|
|
2489
|
+
hosted.lastOutputTime = Date.now();
|
|
2490
|
+
hosted.outputSeq += 1;
|
|
2491
|
+
hosted.terminal.write(data);
|
|
2492
|
+
this.sendBinary(sessionId, Buffer.from(data, "utf-8"), hosted.outputSeq);
|
|
2493
|
+
const oscSequences = extractOscSequences(data);
|
|
2494
|
+
const session = this.deps.sessionManager.getSession(sessionId);
|
|
2495
|
+
const signal = extractOscSignals(data, session?.provider);
|
|
2496
|
+
if (oscSequences.length > 0) {
|
|
2497
|
+
serviceLogger.debug(
|
|
2498
|
+
{
|
|
2499
|
+
sessionId,
|
|
2500
|
+
oscSequences,
|
|
2501
|
+
signal
|
|
2502
|
+
},
|
|
2503
|
+
"Hosted PTY OSC sequences parsed"
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
if (signal?.title) {
|
|
2507
|
+
this.sendTerminalTitle(sessionId, signal.title);
|
|
2508
|
+
}
|
|
2509
|
+
if (signal?.state === "approval_wait") {
|
|
2510
|
+
hosted.currentState = "approval_wait";
|
|
2511
|
+
this.deps.changeSessionState(sessionId, SessionState.WAITING_APPROVAL);
|
|
2512
|
+
this.sendPtyState(sessionId, "approval_wait", { title: signal?.title, tool: signal?.tool });
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
if (shouldReleaseApprovalWait({
|
|
2516
|
+
currentState: hosted.currentState,
|
|
2517
|
+
signalState: signal?.state
|
|
2518
|
+
})) {
|
|
2519
|
+
const nextState = stateAfterApprovalRelease(signal?.state);
|
|
2520
|
+
hosted.currentState = nextState;
|
|
2521
|
+
if (nextState === "turn_complete") {
|
|
2522
|
+
this.deps.onTurnComplete(sessionId);
|
|
2523
|
+
this.deps.changeSessionState(sessionId, SessionState.IDLE);
|
|
2524
|
+
} else {
|
|
2525
|
+
this.deps.changeSessionState(sessionId, SessionState.WORKING);
|
|
2526
|
+
}
|
|
2527
|
+
this.sendPtyState(sessionId, nextState, { title: signal?.title, tool: signal?.tool });
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if ((session?.state === SessionState.WAITING_APPROVAL || hosted.currentState === "approval_wait") && signal?.state !== "turn_complete") {
|
|
2531
|
+
hosted.currentState = "approval_wait";
|
|
2532
|
+
this.sendPtyState(sessionId, "approval_wait", { title: signal?.title, tool: signal?.tool });
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (signal && signal.state !== "working") {
|
|
2536
|
+
hosted.currentState = signal.state;
|
|
2537
|
+
if (signal.state === "turn_complete") {
|
|
2538
|
+
this.deps.onTurnComplete(sessionId);
|
|
2539
|
+
this.deps.changeSessionState(sessionId, SessionState.IDLE);
|
|
2540
|
+
}
|
|
2541
|
+
this.sendPtyState(sessionId, signal.state, { title: signal.title, tool: signal.tool });
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (hosted.currentState !== "working") {
|
|
2545
|
+
hosted.currentState = "working";
|
|
2546
|
+
this.deps.changeSessionState(sessionId, SessionState.WORKING);
|
|
2547
|
+
this.sendPtyState(sessionId, "working");
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
checkIdle(sessionId) {
|
|
2551
|
+
const hosted = this.sessions.get(sessionId);
|
|
2552
|
+
if (!hosted) return;
|
|
2553
|
+
if (hosted.lastOutputTime === 0 || Date.now() - hosted.lastOutputTime <= IDLE_THRESHOLD_MS) {
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
hosted.lastOutputTime = 0;
|
|
2557
|
+
if (hosted.currentState !== "working") return;
|
|
2558
|
+
hosted.currentState = "turn_complete";
|
|
2559
|
+
this.deps.onTurnComplete(sessionId);
|
|
2560
|
+
this.deps.changeSessionState(sessionId, SessionState.IDLE);
|
|
2561
|
+
this.sendPtyState(sessionId, "turn_complete");
|
|
2562
|
+
}
|
|
2563
|
+
sendPtyState(sessionId, state, meta) {
|
|
2564
|
+
const payload = {
|
|
2565
|
+
state,
|
|
2566
|
+
...meta?.title !== void 0 ? { title: meta.title } : {},
|
|
2567
|
+
...meta?.tool !== void 0 ? { tool: meta.tool } : {}
|
|
2568
|
+
};
|
|
2569
|
+
this.deps.relayConnection.sendRaw(
|
|
2570
|
+
JSON.stringify({
|
|
2571
|
+
type: "pty_state",
|
|
2572
|
+
sessionId,
|
|
2573
|
+
payload
|
|
2574
|
+
})
|
|
2575
|
+
);
|
|
2576
|
+
const logPayload = { sessionId, ...payload };
|
|
2577
|
+
if (state === "approval_wait" || state === "turn_complete") {
|
|
2578
|
+
serviceLogger.info(logPayload, "Hosted PTY semantic event pushed");
|
|
2579
|
+
} else {
|
|
2580
|
+
serviceLogger.debug(logPayload, "Hosted PTY semantic event pushed");
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
sendTerminalTitle(sessionId, title) {
|
|
2584
|
+
this.deps.relayConnection.sendRaw(
|
|
2585
|
+
JSON.stringify({
|
|
2586
|
+
type: "terminal_title",
|
|
2587
|
+
sessionId,
|
|
2588
|
+
title
|
|
2589
|
+
})
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
sendBinary(sessionId, data, outputSeq) {
|
|
2593
|
+
const sessionIdBuf = Buffer.from(sessionId, "utf-8");
|
|
2594
|
+
const frame = Buffer.alloc(1 + sessionIdBuf.length + 4 + data.length);
|
|
2595
|
+
frame[0] = sessionIdBuf.length;
|
|
2596
|
+
sessionIdBuf.copy(frame, 1);
|
|
2597
|
+
frame.writeUInt32LE(outputSeq, 1 + sessionIdBuf.length);
|
|
2598
|
+
data.copy(frame, 1 + sessionIdBuf.length + 4);
|
|
2599
|
+
this.deps.relayConnection.sendBinary(frame);
|
|
2600
|
+
}
|
|
2601
|
+
close(sessionId, options) {
|
|
2602
|
+
const hosted = this.sessions.get(sessionId);
|
|
2603
|
+
if (!hosted) return false;
|
|
2604
|
+
this.sessions.delete(sessionId);
|
|
2605
|
+
clearInterval(hosted.idleTimer);
|
|
2606
|
+
if (options.kill) {
|
|
2607
|
+
try {
|
|
2608
|
+
hosted.child.kill();
|
|
2609
|
+
} catch {
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
hosted.terminal.dispose();
|
|
2613
|
+
if (options.notify) {
|
|
2614
|
+
this.sendPtyState(sessionId, "turn_complete");
|
|
2615
|
+
this.deps.sessionManager.terminateSession(sessionId);
|
|
2616
|
+
this.deps.onSessionClosed(sessionId);
|
|
2617
|
+
}
|
|
2618
|
+
return true;
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
2621
|
+
|
|
2622
|
+
// src/serve/relay-session-create-handler.ts
|
|
2623
|
+
function validateSessionCwd(cwd) {
|
|
2624
|
+
if (typeof cwd !== "string" || !cwd.trim()) {
|
|
2625
|
+
return { message: "\u8BF7\u8F93\u5165\u5DE5\u4F5C\u76EE\u5F55", code: ControlErrorCode.INVALID_PATH };
|
|
2626
|
+
}
|
|
2627
|
+
const trimmed = cwd.trim();
|
|
2628
|
+
if (!isAbsolute3(trimmed)) {
|
|
2629
|
+
return { message: "\u5DE5\u4F5C\u76EE\u5F55\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84", code: ControlErrorCode.INVALID_PATH };
|
|
2630
|
+
}
|
|
2631
|
+
try {
|
|
2632
|
+
const stat2 = statSync2(trimmed);
|
|
2633
|
+
return stat2.isDirectory() ? null : { message: "\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u662F\u76EE\u5F55", code: ControlErrorCode.PATH_NOT_DIRECTORY };
|
|
2634
|
+
} catch (err) {
|
|
2635
|
+
return {
|
|
2636
|
+
message: `\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u8BBF\u95EE: ${trimmed}`,
|
|
2637
|
+
code: classifyPathError(err)
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
var RelaySessionCreateHandler = class {
|
|
2642
|
+
constructor(deps) {
|
|
2643
|
+
this.deps = deps;
|
|
2644
|
+
}
|
|
2645
|
+
deps;
|
|
2646
|
+
onSessionCreate(msg) {
|
|
2647
|
+
const requestId = msg.requestId;
|
|
2648
|
+
const cwd = msg.cwd;
|
|
2649
|
+
const cwdError = validateSessionCwd(cwd);
|
|
2650
|
+
if (cwdError) {
|
|
2651
|
+
this.deps.relaySend(
|
|
2652
|
+
JSON.stringify({
|
|
2653
|
+
type: "session_create_response",
|
|
2654
|
+
requestId,
|
|
2655
|
+
sessionId: "",
|
|
2656
|
+
error: cwdError.message,
|
|
2657
|
+
errorCode: cwdError.code
|
|
2658
|
+
})
|
|
2659
|
+
);
|
|
2660
|
+
serviceLogger.warn({ cwd }, "Session create rejected: invalid cwd");
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
const sessionCwd = typeof cwd === "string" ? cwd.trim() : "";
|
|
2664
|
+
const provider = msg.provider;
|
|
2665
|
+
const mode = msg.mode ?? "json";
|
|
2666
|
+
const permissionMode = msg.permissionMode;
|
|
2667
|
+
if (mode === "pty") {
|
|
2668
|
+
this.createHostedPtySession(msg, sessionCwd, provider ?? "claude", permissionMode);
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
if (provider !== "claude") {
|
|
2672
|
+
this.deps.relaySend(
|
|
2673
|
+
JSON.stringify({
|
|
2674
|
+
type: "session_create_response",
|
|
2675
|
+
requestId,
|
|
2676
|
+
sessionId: "",
|
|
2677
|
+
errorCode: ControlErrorCode.PROVIDER_UNSUPPORTED,
|
|
2678
|
+
error: provider === "codex" ? "Codex chat sessions are not supported yet; start a Codex terminal session instead." : "Unsupported provider for JSON session."
|
|
2679
|
+
})
|
|
2680
|
+
);
|
|
2681
|
+
serviceLogger.warn({ provider }, "JSON session create rejected for unsupported provider");
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
const resumeSessionId = msg.resumeSessionId;
|
|
2685
|
+
const streamDelta = msg.streamDelta === true;
|
|
2686
|
+
const name = tildify(sessionCwd);
|
|
2687
|
+
const pendingId = nanoid3();
|
|
2688
|
+
const hook = this.deps.createHookContext(pendingId, provider);
|
|
2689
|
+
const workerPid = this.deps.workerRegistry.spawn(pendingId, {
|
|
2690
|
+
cwd: sessionCwd,
|
|
2691
|
+
resumeSessionId,
|
|
2692
|
+
permissionMode,
|
|
2693
|
+
streamDelta,
|
|
2694
|
+
hook
|
|
2695
|
+
});
|
|
2696
|
+
const paths = sessionPaths(pendingId);
|
|
2697
|
+
let attempt = 0;
|
|
2698
|
+
const maxRetries = 20;
|
|
2699
|
+
const tryConnect = () => {
|
|
2700
|
+
attempt++;
|
|
2701
|
+
this.deps.workerRegistry.connect(pendingId, paths.workerSock).then((sock) => {
|
|
2702
|
+
if (sock) {
|
|
2703
|
+
const session = this.deps.sessionManager.createSession(
|
|
2704
|
+
"json",
|
|
2705
|
+
sessionCwd,
|
|
2706
|
+
workerPid,
|
|
2707
|
+
name,
|
|
2708
|
+
pendingId,
|
|
2709
|
+
provider
|
|
2710
|
+
);
|
|
2711
|
+
if (resumeSessionId) {
|
|
2712
|
+
this.deps.sessionManager.setClaudeSessionId(session.id, resumeSessionId);
|
|
2713
|
+
}
|
|
2714
|
+
this.deps.relaySend(
|
|
2715
|
+
JSON.stringify({ type: "session_create_response", requestId, sessionId: session.id })
|
|
2716
|
+
);
|
|
2717
|
+
if (resumeSessionId) {
|
|
2718
|
+
this.pushHistoryMessages(session.id, resumeSessionId);
|
|
2719
|
+
}
|
|
2720
|
+
serviceLogger.info(
|
|
2721
|
+
{ sessionId: session.id, cwd: sessionCwd },
|
|
2722
|
+
"JSON session created via relay"
|
|
2723
|
+
);
|
|
2724
|
+
this.deps.controlHandlers.pushCommandList(session.id, sessionCwd);
|
|
2725
|
+
this.deps.broadcastSessionSync(session);
|
|
2726
|
+
this.deps.broadcastSessionList();
|
|
2727
|
+
} else if (attempt < maxRetries) {
|
|
2728
|
+
setTimeout(tryConnect, Math.min(100 * attempt, 2e3));
|
|
2729
|
+
} else {
|
|
2730
|
+
this.cleanupPendingJsonSession(pendingId);
|
|
2731
|
+
this.deps.relaySend(
|
|
2732
|
+
JSON.stringify({
|
|
2733
|
+
type: "session_create_response",
|
|
2734
|
+
requestId,
|
|
2735
|
+
sessionId: pendingId,
|
|
2736
|
+
errorCode: ControlErrorCode.WORKER_START_FAILED,
|
|
2737
|
+
error: "Worker failed to start"
|
|
2738
|
+
})
|
|
2739
|
+
);
|
|
2740
|
+
serviceLogger.error({ sessionId: pendingId }, "Worker connection timeout via relay");
|
|
2741
|
+
}
|
|
2742
|
+
});
|
|
2743
|
+
};
|
|
2744
|
+
setTimeout(tryConnect, 100);
|
|
2745
|
+
}
|
|
2746
|
+
cleanupPendingJsonSession(sessionId) {
|
|
2747
|
+
const killed = this.deps.workerRegistry.terminateProcess(sessionId);
|
|
2748
|
+
const paths = sessionPaths(sessionId);
|
|
2749
|
+
rmSync(paths.dir, { recursive: true, force: true });
|
|
2750
|
+
this.deps.cleanupHookContext(sessionId);
|
|
2751
|
+
this.deps.permissionBroker.cleanupSession(sessionId, "Worker failed to start");
|
|
2752
|
+
this.deps.agentStatusRegistry.delete(sessionId);
|
|
2753
|
+
serviceLogger.warn(
|
|
2754
|
+
{ sessionId, killed },
|
|
2755
|
+
"Cleaned up pending JSON session after startup failure"
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
createHostedPtySession(msg, cwd, provider, permissionMode) {
|
|
2759
|
+
if (provider !== "claude" && provider !== "codex") {
|
|
2760
|
+
this.deps.relaySend(
|
|
2761
|
+
JSON.stringify({
|
|
2762
|
+
type: "session_create_response",
|
|
2763
|
+
requestId: msg.requestId,
|
|
2764
|
+
sessionId: "",
|
|
2765
|
+
errorCode: ControlErrorCode.PROVIDER_UNSUPPORTED,
|
|
2766
|
+
error: "Unsupported provider for PTY session."
|
|
2767
|
+
})
|
|
2768
|
+
);
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
const resumeSessionId = msg.resumeSessionId;
|
|
2772
|
+
const pendingId = nanoid3();
|
|
2773
|
+
const name = tildify(cwd);
|
|
2774
|
+
const hook = this.deps.createHookContext(pendingId, provider);
|
|
2775
|
+
try {
|
|
2776
|
+
const pid = this.deps.hostedPtyRegistry.start({
|
|
2777
|
+
sessionId: pendingId,
|
|
2778
|
+
provider,
|
|
2779
|
+
cwd,
|
|
2780
|
+
args: buildHostedPtyArgs(provider, resumeSessionId),
|
|
2781
|
+
permissionMode,
|
|
2782
|
+
hook
|
|
2783
|
+
});
|
|
2784
|
+
const session = this.deps.sessionManager.createSession(
|
|
2785
|
+
"pty",
|
|
2786
|
+
cwd,
|
|
2787
|
+
pid,
|
|
2788
|
+
name,
|
|
2789
|
+
pendingId,
|
|
2790
|
+
provider,
|
|
2791
|
+
"proxy-hosted"
|
|
2792
|
+
);
|
|
2793
|
+
if (resumeSessionId && provider === "claude") {
|
|
2794
|
+
this.deps.sessionManager.setClaudeSessionId(session.id, resumeSessionId);
|
|
2795
|
+
}
|
|
2796
|
+
this.deps.relaySend(
|
|
2797
|
+
JSON.stringify({
|
|
2798
|
+
type: "session_create_response",
|
|
2799
|
+
requestId: msg.requestId,
|
|
2800
|
+
sessionId: session.id,
|
|
2801
|
+
mode: "pty",
|
|
2802
|
+
provider,
|
|
2803
|
+
ptyOwner: "proxy-hosted"
|
|
2804
|
+
})
|
|
2805
|
+
);
|
|
2806
|
+
this.deps.controlHandlers.pushCommandList(session.id, cwd);
|
|
2807
|
+
this.deps.controlHandlers.pushFileTree(session.id, cwd);
|
|
2808
|
+
this.deps.broadcastSessionSync(session);
|
|
2809
|
+
this.deps.broadcastSessionList();
|
|
2810
|
+
serviceLogger.info({ sessionId: session.id, provider, cwd }, "Hosted PTY session created");
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2813
|
+
this.deps.relaySend(
|
|
2814
|
+
JSON.stringify({
|
|
2815
|
+
type: "session_create_response",
|
|
2816
|
+
requestId: msg.requestId,
|
|
2817
|
+
sessionId: "",
|
|
2818
|
+
errorCode: ControlErrorCode.PROCESS_START_FAILED,
|
|
2819
|
+
error
|
|
2820
|
+
})
|
|
2821
|
+
);
|
|
2822
|
+
const providerEnv = this.deps.getProviderEnv();
|
|
2823
|
+
serviceLogger.warn(
|
|
2824
|
+
{
|
|
2825
|
+
provider,
|
|
2826
|
+
cwd,
|
|
2827
|
+
error,
|
|
2828
|
+
claudeBin: providerEnv.CLAUDE_BIN,
|
|
2829
|
+
codexBin: providerEnv.CODEX_BIN,
|
|
2830
|
+
path: providerEnv.PATH
|
|
2831
|
+
},
|
|
2832
|
+
"Hosted PTY session create failed"
|
|
2833
|
+
);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
pushHistoryMessages(sessionId, resumeSessionId) {
|
|
2837
|
+
readSessionMessages(resumeSessionId).then((messages) => {
|
|
2838
|
+
if (messages.length === 0) return;
|
|
2839
|
+
this.deps.relaySend(
|
|
2840
|
+
JSON.stringify({ type: "session_history_messages", sessionId, messages })
|
|
2841
|
+
);
|
|
2842
|
+
serviceLogger.info(
|
|
2843
|
+
{ sessionId, resumeSessionId, messageCount: messages.length },
|
|
2844
|
+
"History messages sent for resumed session"
|
|
2845
|
+
);
|
|
2846
|
+
}).catch((err) => {
|
|
2847
|
+
serviceLogger.warn(
|
|
2848
|
+
{ sessionId, error: String(err) },
|
|
2849
|
+
"Failed to read session history messages"
|
|
2850
|
+
);
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
|
|
2855
|
+
// src/serve/relay-router.ts
|
|
2856
|
+
var RelayRouter = class {
|
|
2857
|
+
constructor(deps) {
|
|
2858
|
+
this.deps = deps;
|
|
2859
|
+
this.historyHandlers = new RelayHistoryHandlers({
|
|
2860
|
+
relaySend: deps.relaySend,
|
|
2861
|
+
sessionManager: deps.sessionManager,
|
|
2862
|
+
permissionBroker: deps.permissionBroker
|
|
2863
|
+
});
|
|
2864
|
+
this.inputHandlers = new RelayInputHandlers({
|
|
2865
|
+
sessionManager: deps.sessionManager,
|
|
2866
|
+
workerRegistry: deps.workerRegistry,
|
|
2867
|
+
terminalSockets: deps.terminalSockets,
|
|
2868
|
+
hostedPtyRegistry: deps.hostedPtyRegistry,
|
|
2869
|
+
jsonObserver: deps.jsonObserver
|
|
2870
|
+
});
|
|
2871
|
+
this.resourceHandlers = new RelayResourceHandlers({
|
|
2872
|
+
relaySend: deps.relaySend,
|
|
2873
|
+
controlHandlers: deps.controlHandlers,
|
|
2874
|
+
sessionManager: deps.sessionManager,
|
|
2875
|
+
envName: deps.envName,
|
|
2876
|
+
getProviderEnv: deps.getProviderEnv,
|
|
2877
|
+
getAgentCliSuggestions: deps.getAgentCliSuggestions,
|
|
2878
|
+
setAgentCliPath: deps.setAgentCliPath
|
|
2879
|
+
});
|
|
2880
|
+
this.permissionHandlers = new RelayPermissionHandlers({
|
|
2881
|
+
relaySend: deps.relaySend,
|
|
2882
|
+
permissionBroker: deps.permissionBroker,
|
|
2883
|
+
hookEventRouter: deps.hookEventRouter,
|
|
2884
|
+
workerRegistry: deps.workerRegistry
|
|
2885
|
+
});
|
|
2886
|
+
this.sessionCreateHandler = new RelaySessionCreateHandler({
|
|
2887
|
+
relaySend: deps.relaySend,
|
|
2888
|
+
workerRegistry: deps.workerRegistry,
|
|
2889
|
+
sessionManager: deps.sessionManager,
|
|
2890
|
+
hostedPtyRegistry: deps.hostedPtyRegistry,
|
|
2891
|
+
controlHandlers: deps.controlHandlers,
|
|
2892
|
+
permissionBroker: deps.permissionBroker,
|
|
2893
|
+
agentStatusRegistry: deps.agentStatusRegistry,
|
|
2894
|
+
getProviderEnv: deps.getProviderEnv,
|
|
2895
|
+
createHookContext: deps.createHookContext,
|
|
2896
|
+
cleanupHookContext: deps.cleanupHookContext,
|
|
2897
|
+
broadcastSessionSync: deps.broadcastSessionSync,
|
|
2898
|
+
broadcastSessionList: deps.broadcastSessionList
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
deps;
|
|
2902
|
+
historyHandlers;
|
|
2903
|
+
inputHandlers;
|
|
2904
|
+
permissionHandlers;
|
|
2905
|
+
resourceHandlers;
|
|
2906
|
+
sessionCreateHandler;
|
|
2907
|
+
handle(parsed) {
|
|
2908
|
+
const type = parsed.type;
|
|
2909
|
+
if (!type) {
|
|
2910
|
+
serviceLogger.warn("Relay message without type discriminator");
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
const handler = this.handlers[type];
|
|
2914
|
+
if (!handler) {
|
|
2915
|
+
serviceLogger.warn({ type }, "Unhandled relay message type");
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
try {
|
|
2919
|
+
handler.call(this, parsed);
|
|
2920
|
+
} catch (err) {
|
|
2921
|
+
serviceLogger.warn({ type, error: String(err) }, "Relay handler threw");
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
handlers = {
|
|
2925
|
+
user_input: (msg) => this.inputHandlers.onUserInput(msg),
|
|
2926
|
+
remote_input_raw: (msg) => this.inputHandlers.onRemoteInputRaw(msg),
|
|
2927
|
+
tool_approve: (msg) => this.permissionHandlers.onToolApprove(msg),
|
|
2928
|
+
tool_deny: (msg) => this.permissionHandlers.onToolDeny(msg),
|
|
2929
|
+
proxy_info_request: (msg) => this.resourceHandlers.onProxyInfoRequest(msg),
|
|
2930
|
+
agent_cli_config_update: (msg) => this.resourceHandlers.onAgentCliConfigUpdate(msg),
|
|
2931
|
+
dir_list_request: (msg) => this.resourceHandlers.onDirListRequest(msg),
|
|
2932
|
+
dir_create_request: (msg) => this.resourceHandlers.onDirCreateRequest(msg),
|
|
2933
|
+
session_create: (msg) => this.sessionCreateHandler.onSessionCreate(msg),
|
|
2934
|
+
session_messages_request: (msg) => this.historyHandlers.onSessionMessagesRequest(msg),
|
|
2935
|
+
session_resources_request: (msg) => this.resourceHandlers.onSessionResourcesRequest(msg),
|
|
2936
|
+
agent_status_request: (msg) => this.onAgentStatusRequest(msg),
|
|
2937
|
+
permission_request_delivered: (msg) => this.permissionHandlers.onPermissionRequestDelivered(msg),
|
|
2938
|
+
session_terminate: (msg) => this.onSessionTerminate(msg),
|
|
2939
|
+
session_worker_abort: (msg) => this.onSessionWorkerAbort(msg),
|
|
2940
|
+
session_history_request: (msg) => this.deps.controlHandlers.handleSessionHistoryRequest({
|
|
2941
|
+
requestId: msg.requestId
|
|
2942
|
+
}),
|
|
2943
|
+
session_list: () => this.onSessionList(),
|
|
2944
|
+
permission_mode_change: (msg) => this.onPermissionModeChange(msg),
|
|
2945
|
+
session_subscribe: (msg) => this.onSessionSubscribe(msg),
|
|
2946
|
+
terminal_resize_request: (msg) => this.onTerminalResizeRequest(msg)
|
|
2947
|
+
};
|
|
2948
|
+
onAgentStatusRequest(msg) {
|
|
2949
|
+
const sid = msg.sessionId;
|
|
2950
|
+
const requestId = msg.requestId;
|
|
2951
|
+
if (sid) {
|
|
2952
|
+
const status = this.deps.agentStatusRegistry.get(sid);
|
|
2953
|
+
const statuses2 = status && this.deps.sessionManager.getSession(sid) ? [{ sessionId: sid, payload: status }] : [];
|
|
2954
|
+
this.deps.relaySend(JSON.stringify({ type: "agent_status_response", requestId, statuses: statuses2 }));
|
|
2955
|
+
serviceLogger.info({ sessionId: sid, count: statuses2.length }, "Agent status snapshot sent");
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
const statuses = [];
|
|
2959
|
+
for (const { sessionId, status } of this.deps.agentStatusRegistry.list()) {
|
|
2960
|
+
if (!this.deps.sessionManager.getSession(sessionId)) continue;
|
|
2961
|
+
statuses.push({ sessionId, payload: status });
|
|
2962
|
+
}
|
|
2963
|
+
this.deps.relaySend(JSON.stringify({ type: "agent_status_response", requestId, statuses }));
|
|
2964
|
+
serviceLogger.info({ count: statuses.length }, "Agent status snapshot sent");
|
|
2965
|
+
}
|
|
2966
|
+
onSessionTerminate(msg) {
|
|
2967
|
+
const sid = msg.sessionId;
|
|
2968
|
+
if (!sid) return;
|
|
2969
|
+
const result = terminateSessionByOwnership(this.deps, sid);
|
|
2970
|
+
serviceLogger.info(
|
|
2971
|
+
{ sessionId: sid, success: result.success, action: result.action },
|
|
2972
|
+
"Session termination handled via relay"
|
|
2973
|
+
);
|
|
2974
|
+
if (result.action !== "terminate_hosted_pty") this.deps.broadcastSessionList();
|
|
2975
|
+
}
|
|
2976
|
+
onSessionWorkerAbort(msg) {
|
|
2977
|
+
const sid = msg.sessionId;
|
|
2978
|
+
if (!sid) return;
|
|
2979
|
+
const session = this.deps.sessionManager.getSession(sid);
|
|
2980
|
+
if (!session) {
|
|
2981
|
+
serviceLogger.warn({ sessionId: sid }, "session_worker_abort: session not found");
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
if (session.state === SessionState.TERMINATED) {
|
|
2985
|
+
serviceLogger.info({ sessionId: sid }, "session_worker_abort: already terminated, dropping");
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
if (session.mode === "pty") {
|
|
2989
|
+
const ts = this.deps.terminalSockets.get(sid);
|
|
2990
|
+
if (this.deps.hostedPtyRegistry.write(sid, "")) {
|
|
2991
|
+
serviceLogger.info({ sessionId: sid }, "session_worker_abort: Ctrl+C sent to hosted PTY");
|
|
2992
|
+
} else if (ts?.writable) {
|
|
2993
|
+
ts.write(serializeIpc({ type: "pty_input", sessionId: sid, data: "" }));
|
|
2994
|
+
serviceLogger.info({ sessionId: sid }, "session_worker_abort: Ctrl+C sent to PTY");
|
|
2995
|
+
} else {
|
|
2996
|
+
serviceLogger.warn(
|
|
2997
|
+
{ sessionId: sid },
|
|
2998
|
+
"session_worker_abort: PTY terminal socket unavailable"
|
|
2999
|
+
);
|
|
3000
|
+
}
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
try {
|
|
3004
|
+
process.kill(session.pid, "SIGINT");
|
|
3005
|
+
serviceLogger.info(
|
|
3006
|
+
{ sessionId: sid, pid: session.pid },
|
|
3007
|
+
"session_worker_abort: SIGINT sent to worker"
|
|
3008
|
+
);
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
serviceLogger.warn(
|
|
3011
|
+
{ sessionId: sid, pid: session.pid, error: String(err) },
|
|
3012
|
+
"session_worker_abort: kill failed"
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
onSessionList() {
|
|
3017
|
+
this.deps.broadcastSessionList();
|
|
3018
|
+
serviceLogger.info("Session list sent via relay");
|
|
3019
|
+
}
|
|
3020
|
+
onPermissionModeChange(msg) {
|
|
3021
|
+
const sid = msg.sessionId;
|
|
3022
|
+
const mode = msg.mode;
|
|
3023
|
+
if (!sid) {
|
|
3024
|
+
serviceLogger.info(
|
|
3025
|
+
{ mode },
|
|
3026
|
+
"Permission mode change received via relay (global, no sessionId)"
|
|
3027
|
+
);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
const session = this.deps.sessionManager.getSession(sid);
|
|
3031
|
+
if (session?.mode !== "pty") {
|
|
3032
|
+
serviceLogger.info(
|
|
3033
|
+
{ sessionId: sid, mode },
|
|
3034
|
+
"Permission mode change received for JSON session (no-op, not supported)"
|
|
3035
|
+
);
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
const ts = this.deps.terminalSockets.get(sid);
|
|
3039
|
+
if (this.deps.hostedPtyRegistry.write(sid, "\x1B[Z")) {
|
|
3040
|
+
serviceLogger.info(
|
|
3041
|
+
{ sessionId: sid, mode },
|
|
3042
|
+
"Permission mode cycle: Shift+Tab sent to hosted PTY"
|
|
3043
|
+
);
|
|
3044
|
+
} else if (ts?.writable) {
|
|
3045
|
+
ts.write(serializeIpc({ type: "pty_input", sessionId: sid, data: "\x1B[Z" }));
|
|
3046
|
+
serviceLogger.info({ sessionId: sid, mode }, "Permission mode cycle: Shift+Tab sent to PTY");
|
|
3047
|
+
} else {
|
|
3048
|
+
serviceLogger.warn(
|
|
3049
|
+
{ sessionId: sid },
|
|
3050
|
+
"Permission mode cycle: PTY terminal socket unavailable"
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
onSessionSubscribe(msg) {
|
|
3055
|
+
const sid = msg.sessionId;
|
|
3056
|
+
const requestId = msg.requestId;
|
|
3057
|
+
if (!sid) return;
|
|
3058
|
+
const ts = this.deps.terminalSockets.get(sid);
|
|
3059
|
+
if (this.deps.hostedPtyRegistry.snapshot(sid, requestId)) {
|
|
3060
|
+
serviceLogger.info({ sessionId: sid, requestId }, "Subscribe handled by hosted PTY");
|
|
3061
|
+
} else if (ts?.writable) {
|
|
3062
|
+
ts.write(serializeIpc({ type: "pty_subscribe", sessionId: sid, requestId }));
|
|
3063
|
+
serviceLogger.info({ sessionId: sid, requestId }, "Subscribe forwarded to terminal");
|
|
3064
|
+
} else {
|
|
3065
|
+
serviceLogger.warn({ sessionId: sid }, "Subscribe failed: terminal socket not available");
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
onTerminalResizeRequest(msg) {
|
|
3069
|
+
const sid = msg.sessionId;
|
|
3070
|
+
const cols = msg.cols;
|
|
3071
|
+
const rows = msg.rows;
|
|
3072
|
+
if (!sid || !cols || !rows) return;
|
|
3073
|
+
if (!this.deps.hostedPtyRegistry.resize(sid, cols, rows)) {
|
|
3074
|
+
serviceLogger.debug({ sessionId: sid, cols, rows }, "Resize request ignored: not hosted PTY");
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
|
|
3079
|
+
// src/serve/json-observer.ts
|
|
3080
|
+
var JsonObserver = class {
|
|
3081
|
+
constructor(deps) {
|
|
3082
|
+
this.deps = deps;
|
|
3083
|
+
}
|
|
3084
|
+
deps;
|
|
3085
|
+
// 用户消息注入 worker(relay-router 收到 user_input)→ 进入 WORKING
|
|
3086
|
+
onTurnStart(sessionId) {
|
|
3087
|
+
this.deps.changeSessionState(sessionId, SessionState.WORKING);
|
|
3088
|
+
this.deps.emitAgentStatus?.(sessionId, "thinking");
|
|
3089
|
+
}
|
|
3090
|
+
// stream-json result event 到达 → turn 结束回 IDLE
|
|
3091
|
+
onTurnResult(sessionId) {
|
|
3092
|
+
this.deps.changeSessionState(sessionId, SessionState.IDLE);
|
|
3093
|
+
this.deps.emitAgentStatus?.(sessionId, "idle");
|
|
3094
|
+
}
|
|
3095
|
+
// control_request event 到达 → worker 阻塞等审批
|
|
3096
|
+
onApprovalRequested(sessionId) {
|
|
3097
|
+
this.deps.changeSessionState(sessionId, SessionState.WAITING_APPROVAL);
|
|
3098
|
+
this.deps.emitAgentStatus?.(sessionId, "waiting_permission");
|
|
3099
|
+
}
|
|
3100
|
+
// 观察通道失联 → ERROR,待人工干预或后续 terminate
|
|
3101
|
+
onChannelBroken(sessionId) {
|
|
3102
|
+
this.deps.changeSessionState(sessionId, SessionState.ERROR);
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
|
|
3106
|
+
// src/serve/permission-broker.ts
|
|
3107
|
+
var PermissionBroker = class {
|
|
3108
|
+
pending = /* @__PURE__ */ new Map();
|
|
3109
|
+
request(request) {
|
|
3110
|
+
const existing = this.pending.get(request.requestId);
|
|
3111
|
+
if (existing) {
|
|
3112
|
+
return Promise.resolve({
|
|
3113
|
+
behavior: "deny",
|
|
3114
|
+
message: "Duplicate permission request id."
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
return new Promise((resolve) => {
|
|
3118
|
+
this.pending.set(request.requestId, {
|
|
3119
|
+
...request,
|
|
3120
|
+
source: "hook",
|
|
3121
|
+
resolve,
|
|
3122
|
+
createdAt: Date.now()
|
|
3123
|
+
});
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
registerWorkerRequest(request, onDecision) {
|
|
3127
|
+
const existing = this.pending.get(request.requestId);
|
|
3128
|
+
if (existing) {
|
|
3129
|
+
onDecision({
|
|
3130
|
+
behavior: "deny",
|
|
3131
|
+
message: "Duplicate permission request id."
|
|
3132
|
+
});
|
|
3133
|
+
return false;
|
|
3134
|
+
}
|
|
3135
|
+
this.pending.set(request.requestId, {
|
|
3136
|
+
...request,
|
|
3137
|
+
source: "worker",
|
|
3138
|
+
resolve: onDecision,
|
|
3139
|
+
createdAt: Date.now()
|
|
3140
|
+
});
|
|
3141
|
+
return true;
|
|
3142
|
+
}
|
|
3143
|
+
resolve(requestId, decision) {
|
|
3144
|
+
const pending = this.pending.get(requestId);
|
|
3145
|
+
if (!pending) return false;
|
|
3146
|
+
this.pending.delete(requestId);
|
|
3147
|
+
pending.resolve(decision);
|
|
3148
|
+
return true;
|
|
3149
|
+
}
|
|
3150
|
+
markDelivered(requestId) {
|
|
3151
|
+
const pending = this.pending.get(requestId);
|
|
3152
|
+
if (!pending) return false;
|
|
3153
|
+
pending.deliveredAt = Date.now();
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
get(requestId) {
|
|
3157
|
+
const pending = this.pending.get(requestId);
|
|
3158
|
+
if (!pending) return null;
|
|
3159
|
+
return {
|
|
3160
|
+
requestId: pending.requestId,
|
|
3161
|
+
sessionId: pending.sessionId,
|
|
3162
|
+
provider: pending.provider,
|
|
3163
|
+
source: pending.source,
|
|
3164
|
+
toolName: pending.toolName,
|
|
3165
|
+
input: pending.input,
|
|
3166
|
+
createdAt: pending.createdAt,
|
|
3167
|
+
...pending.deliveredAt !== void 0 ? { deliveredAt: pending.deliveredAt } : {}
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
cleanupSession(sessionId, reason) {
|
|
3171
|
+
for (const [requestId, pending] of this.pending) {
|
|
3172
|
+
if (pending.sessionId !== sessionId) continue;
|
|
3173
|
+
this.pending.delete(requestId);
|
|
3174
|
+
pending.resolve({ behavior: "deny", message: reason });
|
|
3175
|
+
serviceLogger.info({ sessionId, requestId, reason }, "Pending hook permission dropped");
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
listSession(sessionId) {
|
|
3179
|
+
const out = [];
|
|
3180
|
+
for (const pending of this.pending.values()) {
|
|
3181
|
+
if (pending.sessionId !== sessionId) continue;
|
|
3182
|
+
out.push({
|
|
3183
|
+
requestId: pending.requestId,
|
|
3184
|
+
sessionId: pending.sessionId,
|
|
3185
|
+
provider: pending.provider,
|
|
3186
|
+
source: pending.source,
|
|
3187
|
+
toolName: pending.toolName,
|
|
3188
|
+
input: pending.input,
|
|
3189
|
+
createdAt: pending.createdAt,
|
|
3190
|
+
...pending.deliveredAt !== void 0 ? { deliveredAt: pending.deliveredAt } : {}
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
return out;
|
|
3194
|
+
}
|
|
3195
|
+
};
|
|
3196
|
+
|
|
3197
|
+
// src/serve/hook-event-router.ts
|
|
3198
|
+
function hookPayloadRecord(value) {
|
|
3199
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3200
|
+
}
|
|
3201
|
+
function toolNameFromPayload(payload) {
|
|
3202
|
+
return typeof payload.toolName === "string" ? payload.toolName : typeof payload.tool_name === "string" ? payload.tool_name : "unknown";
|
|
3203
|
+
}
|
|
3204
|
+
function toolInputFromPayload(payload) {
|
|
3205
|
+
return hookPayloadRecord(payload.input ?? payload.tool_input);
|
|
3206
|
+
}
|
|
3207
|
+
var HookEventRouter = class {
|
|
3208
|
+
constructor(deps) {
|
|
3209
|
+
this.deps = deps;
|
|
3210
|
+
}
|
|
3211
|
+
deps;
|
|
3212
|
+
handle(event) {
|
|
3213
|
+
switch (event.event) {
|
|
3214
|
+
case "SessionStart":
|
|
3215
|
+
this.deps.changeSessionState(event.sessionId, SessionState.IDLE);
|
|
3216
|
+
this.forwardAgentStatus(event, "idle");
|
|
3217
|
+
break;
|
|
3218
|
+
case "UserPromptSubmit":
|
|
3219
|
+
this.deps.changeSessionState(event.sessionId, SessionState.WORKING);
|
|
3220
|
+
this.forwardAgentStatus(event, "thinking");
|
|
3221
|
+
break;
|
|
3222
|
+
case "PostToolUse":
|
|
3223
|
+
case "PostToolUseFailure":
|
|
3224
|
+
this.deps.changeSessionState(event.sessionId, SessionState.WORKING);
|
|
3225
|
+
this.forwardAgentStatus(event, "outputting");
|
|
3226
|
+
break;
|
|
3227
|
+
case "Stop":
|
|
3228
|
+
this.deps.changeSessionState(event.sessionId, SessionState.IDLE);
|
|
3229
|
+
this.forwardAgentStatus(event, "idle");
|
|
3230
|
+
break;
|
|
3231
|
+
case "PermissionRequest":
|
|
3232
|
+
this.forwardPermissionRequest(event);
|
|
3233
|
+
break;
|
|
3234
|
+
case "PreToolUse":
|
|
3235
|
+
this.forwardToolUse(event);
|
|
3236
|
+
break;
|
|
3237
|
+
default:
|
|
3238
|
+
serviceLogger.debug(
|
|
3239
|
+
{ sessionId: event.sessionId, provider: event.provider, event: event.event },
|
|
3240
|
+
"Unknown provider hook event ignored"
|
|
3241
|
+
);
|
|
3242
|
+
break;
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
onPermissionResolved(sessionId, provider, requestId, outcome, context) {
|
|
3246
|
+
this.deps.changeSessionState(sessionId, SessionState.WORKING);
|
|
3247
|
+
if (outcome === "deny") {
|
|
3248
|
+
this.deps.changeSessionState(sessionId, SessionState.IDLE);
|
|
3249
|
+
}
|
|
3250
|
+
this.forwardAgentStatus(
|
|
3251
|
+
{
|
|
3252
|
+
sessionId,
|
|
3253
|
+
provider,
|
|
3254
|
+
event: "PermissionResolved",
|
|
3255
|
+
requestId,
|
|
3256
|
+
payload: {}
|
|
3257
|
+
},
|
|
3258
|
+
outcome === "allow" ? "tool_use" : "idle",
|
|
3259
|
+
{
|
|
3260
|
+
toolName: context?.toolName,
|
|
3261
|
+
toolInput: context?.toolInput,
|
|
3262
|
+
permissionResolution: { requestId, outcome }
|
|
3263
|
+
}
|
|
3264
|
+
);
|
|
3265
|
+
serviceLogger.info({ sessionId, requestId, outcome }, "Hook permission resolved");
|
|
3266
|
+
}
|
|
3267
|
+
forwardPermissionRequest(event) {
|
|
3268
|
+
const requestId = event.requestId ?? `${event.sessionId}:${Date.now()}`;
|
|
3269
|
+
const toolName = toolNameFromPayload(event.payload);
|
|
3270
|
+
const input = toolInputFromPayload(event.payload);
|
|
3271
|
+
this.deps.changeSessionState(event.sessionId, SessionState.WAITING_APPROVAL);
|
|
3272
|
+
this.forwardAgentStatus(event, "waiting_permission", {
|
|
3273
|
+
toolName,
|
|
3274
|
+
toolInput: input,
|
|
3275
|
+
permissionRequest: {
|
|
3276
|
+
requestId,
|
|
3277
|
+
toolName,
|
|
3278
|
+
input
|
|
3279
|
+
}
|
|
3280
|
+
});
|
|
3281
|
+
const seq = this.deps.nextSeq?.(event.sessionId) ?? new SeqCounter(event.sessionId).next();
|
|
3282
|
+
const envelope = buildMessage(
|
|
3283
|
+
"tool_use_request",
|
|
3284
|
+
event.sessionId,
|
|
3285
|
+
seq,
|
|
3286
|
+
{
|
|
3287
|
+
toolName,
|
|
3288
|
+
toolId: requestId,
|
|
3289
|
+
parameters: input
|
|
3290
|
+
},
|
|
3291
|
+
"proxy"
|
|
3292
|
+
);
|
|
3293
|
+
this.deps.relayConnection.sendEnvelope(envelope);
|
|
3294
|
+
}
|
|
3295
|
+
forwardToolUse(event) {
|
|
3296
|
+
const toolName = toolNameFromPayload(event.payload);
|
|
3297
|
+
const input = toolInputFromPayload(event.payload);
|
|
3298
|
+
this.forwardAgentStatus(event, "tool_use", {
|
|
3299
|
+
toolName,
|
|
3300
|
+
toolInput: input
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
forwardAgentStatus(event, phase, extra) {
|
|
3304
|
+
const payload = {
|
|
3305
|
+
provider: event.provider,
|
|
3306
|
+
phase,
|
|
3307
|
+
seq: this.nextSeq(event.sessionId),
|
|
3308
|
+
updatedAt: Date.now(),
|
|
3309
|
+
...extra
|
|
3310
|
+
};
|
|
3311
|
+
this.deps.agentStatusRegistry.set(event.sessionId, payload);
|
|
3312
|
+
this.deps.relayConnection.sendRaw(
|
|
3313
|
+
JSON.stringify({
|
|
3314
|
+
type: "agent_status",
|
|
3315
|
+
sessionId: event.sessionId,
|
|
3316
|
+
payload
|
|
3317
|
+
})
|
|
3318
|
+
);
|
|
3319
|
+
}
|
|
3320
|
+
nextSeq(sessionId) {
|
|
3321
|
+
return this.deps.nextSeq?.(sessionId) ?? new SeqCounter(sessionId).next();
|
|
3322
|
+
}
|
|
3323
|
+
};
|
|
3324
|
+
|
|
3325
|
+
// src/serve/agent-status-registry.ts
|
|
3326
|
+
var AgentStatusRegistry = class {
|
|
3327
|
+
statuses = /* @__PURE__ */ new Map();
|
|
3328
|
+
set(sessionId, status) {
|
|
3329
|
+
const current = this.statuses.get(sessionId);
|
|
3330
|
+
if (current && current.seq > status.seq) return;
|
|
3331
|
+
this.statuses.set(sessionId, status);
|
|
3332
|
+
}
|
|
3333
|
+
get(sessionId) {
|
|
3334
|
+
return this.statuses.get(sessionId) ?? null;
|
|
3335
|
+
}
|
|
3336
|
+
list() {
|
|
3337
|
+
return Array.from(this.statuses, ([sessionId, status]) => ({ sessionId, status }));
|
|
3338
|
+
}
|
|
3339
|
+
delete(sessionId) {
|
|
3340
|
+
this.statuses.delete(sessionId);
|
|
3341
|
+
}
|
|
3342
|
+
};
|
|
3343
|
+
|
|
3344
|
+
// src/serve/session-broadcast.ts
|
|
3345
|
+
function toSessionListPayload(s) {
|
|
3346
|
+
return {
|
|
3347
|
+
sessionId: s.id,
|
|
3348
|
+
mode: s.mode,
|
|
3349
|
+
provider: s.provider,
|
|
3350
|
+
...s.ptyOwner !== void 0 ? { ptyOwner: s.ptyOwner } : {},
|
|
3351
|
+
state: s.state,
|
|
3352
|
+
lastActive: s.updatedAt,
|
|
3353
|
+
...s.name !== void 0 ? { name: s.name } : {}
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
function pushSessionStatus(relay, sessionManager, sessionId) {
|
|
3357
|
+
const session = sessionManager.getSession(sessionId);
|
|
3358
|
+
if (!session) return;
|
|
3359
|
+
try {
|
|
3360
|
+
const envelope = buildMessage(
|
|
3361
|
+
"session_status",
|
|
3362
|
+
session.id,
|
|
3363
|
+
Date.now(),
|
|
3364
|
+
{ sessionId: session.id, state: session.state, lastActive: session.updatedAt },
|
|
3365
|
+
"proxy"
|
|
3366
|
+
);
|
|
3367
|
+
relay.sendEnvelope(envelope);
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
serviceLogger.debug({ sessionId, error: String(err) }, "Failed to push session_status");
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
function broadcastSessionList(relay, sessionManager) {
|
|
3373
|
+
relay.sendRaw(
|
|
3374
|
+
JSON.stringify({
|
|
3375
|
+
type: "session_list",
|
|
3376
|
+
sessionId: "",
|
|
3377
|
+
seq: 0,
|
|
3378
|
+
timestamp: Date.now(),
|
|
3379
|
+
source: "proxy",
|
|
3380
|
+
version: "1",
|
|
3381
|
+
payload: { sessions: sessionManager.listSessions().map(toSessionListPayload) }
|
|
3382
|
+
})
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
function broadcastSessionSync(relay, session) {
|
|
3386
|
+
relay.sendRaw(
|
|
3387
|
+
JSON.stringify({
|
|
3388
|
+
type: "session_sync",
|
|
3389
|
+
sessions: [
|
|
3390
|
+
{
|
|
3391
|
+
id: session.id,
|
|
3392
|
+
mode: session.mode,
|
|
3393
|
+
provider: session.provider,
|
|
3394
|
+
...session.ptyOwner !== void 0 ? { ptyOwner: session.ptyOwner } : {},
|
|
3395
|
+
state: session.state
|
|
3396
|
+
}
|
|
3397
|
+
]
|
|
3398
|
+
})
|
|
3399
|
+
);
|
|
3400
|
+
}
|
|
3401
|
+
function changeSessionState(sessionManager, relay, sessionId, next) {
|
|
3402
|
+
if (!sessionManager.getSession(sessionId)) return false;
|
|
3403
|
+
const changed = sessionManager.updateState(sessionId, next);
|
|
3404
|
+
if (changed) pushSessionStatus(relay, sessionManager, sessionId);
|
|
3405
|
+
return changed;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
// src/serve/service-files.ts
|
|
3409
|
+
import { execSync } from "child_process";
|
|
3410
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, unlinkSync as unlinkSync2 } from "fs";
|
|
3411
|
+
import { hostname } from "os";
|
|
3412
|
+
import { connect as connect2 } from "net";
|
|
3413
|
+
function tryConnectSocket(sockPath) {
|
|
3414
|
+
return new Promise((resolve) => {
|
|
3415
|
+
const s = connect2(sockPath);
|
|
3416
|
+
s.on("connect", () => resolve(s));
|
|
3417
|
+
s.on("error", () => resolve(null));
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
function isProcessAlive(pid) {
|
|
3421
|
+
try {
|
|
3422
|
+
process.kill(pid, 0);
|
|
3423
|
+
return true;
|
|
3424
|
+
} catch {
|
|
3425
|
+
return false;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
async function cleanupStaleResources() {
|
|
3429
|
+
if (existsSync5(SOCK_PATH)) {
|
|
3430
|
+
const existing = await tryConnectSocket(SOCK_PATH);
|
|
3431
|
+
if (existing) {
|
|
3432
|
+
existing.destroy();
|
|
3433
|
+
const msg = `Another service is already running on ${SOCK_PATH}`;
|
|
3434
|
+
serviceLogger.error(msg);
|
|
3435
|
+
console.error(msg);
|
|
3436
|
+
process.exit(1);
|
|
3437
|
+
}
|
|
3438
|
+
unlinkSync2(SOCK_PATH);
|
|
3439
|
+
serviceLogger.info("Removed stale socket file");
|
|
3440
|
+
}
|
|
3441
|
+
if (existsSync5(PID_PATH)) {
|
|
3442
|
+
const pidStr = readFileSync5(PID_PATH, "utf-8").trim();
|
|
3443
|
+
const pid = parseInt(pidStr, 10);
|
|
3444
|
+
if (!isNaN(pid) && isProcessAlive(pid)) {
|
|
3445
|
+
const msg = `Another service is already running with PID ${pid}`;
|
|
3446
|
+
serviceLogger.error(msg);
|
|
3447
|
+
console.error(msg);
|
|
3448
|
+
process.exit(1);
|
|
3449
|
+
}
|
|
3450
|
+
unlinkSync2(PID_PATH);
|
|
3451
|
+
serviceLogger.info("Removed stale PID file");
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
function getProxyName() {
|
|
3455
|
+
return process.env.DEV_ANYWHERE_PROXY_NAME || getComputerName() || hostname();
|
|
3456
|
+
}
|
|
3457
|
+
function getComputerName() {
|
|
3458
|
+
try {
|
|
3459
|
+
return execSync("scutil --get ComputerName", { stdio: ["pipe", "pipe", "ignore"] }).toString().trim() || null;
|
|
3460
|
+
} catch {
|
|
3461
|
+
return null;
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// src/serve/pty-state-guard.ts
|
|
3466
|
+
function shouldPromotePtyActivityToWorking(session, pendingApprovalCount) {
|
|
3467
|
+
if (!session || session.mode !== "pty") return false;
|
|
3468
|
+
if (pendingApprovalCount > 0) return false;
|
|
3469
|
+
return session.state === SessionState.IDLE || session.state === SessionState.WAITING_APPROVAL;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// src/serve/pty-semantic-lifecycle.ts
|
|
3473
|
+
function resolvePtySemanticSessionTransitions(currentState, semanticState) {
|
|
3474
|
+
if (semanticState !== "turn_complete") return [];
|
|
3475
|
+
if (currentState === SessionState.WAITING_APPROVAL) {
|
|
3476
|
+
return [SessionState.IDLE];
|
|
3477
|
+
}
|
|
3478
|
+
if (currentState === SessionState.WORKING) {
|
|
3479
|
+
return [SessionState.IDLE];
|
|
3480
|
+
}
|
|
3481
|
+
return [];
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
// src/serve/terminal-ipc.ts
|
|
3485
|
+
function handleTerminalConnection(socket, deps) {
|
|
3486
|
+
const {
|
|
3487
|
+
sessionManager,
|
|
3488
|
+
workerRegistry,
|
|
3489
|
+
terminalSockets,
|
|
3490
|
+
hostedPtyRegistry,
|
|
3491
|
+
relayConnection,
|
|
3492
|
+
controlHandlers,
|
|
3493
|
+
agentStatusRegistry,
|
|
3494
|
+
permissionBroker,
|
|
3495
|
+
createHookContext,
|
|
3496
|
+
emitAgentStatus,
|
|
3497
|
+
resolveInterruptedApprovals: resolveInterruptedApprovals2,
|
|
3498
|
+
config
|
|
3499
|
+
} = deps;
|
|
3500
|
+
createIpcReader(
|
|
3501
|
+
socket,
|
|
3502
|
+
(msg) => {
|
|
3503
|
+
switch (msg.type) {
|
|
3504
|
+
case "session_create_request": {
|
|
3505
|
+
if (msg.mode !== "pty") {
|
|
3506
|
+
socket.write(
|
|
3507
|
+
serializeIpc({
|
|
3508
|
+
type: "session_create_response",
|
|
3509
|
+
sessionId: "",
|
|
3510
|
+
error: `Unsupported mode via IPC: ${msg.mode}`
|
|
3511
|
+
})
|
|
3512
|
+
);
|
|
3513
|
+
break;
|
|
3514
|
+
}
|
|
3515
|
+
const provider = msg.provider;
|
|
3516
|
+
const existing = msg.sessionId ? sessionManager.getSession(msg.sessionId) : void 0;
|
|
3517
|
+
const session = existing ?? sessionManager.createSession(
|
|
3518
|
+
"pty",
|
|
3519
|
+
msg.cwd,
|
|
3520
|
+
msg.pid,
|
|
3521
|
+
msg.name,
|
|
3522
|
+
msg.sessionId,
|
|
3523
|
+
provider,
|
|
3524
|
+
"local-terminal"
|
|
3525
|
+
);
|
|
3526
|
+
if (existing) {
|
|
3527
|
+
sessionManager.setPid(session.id, msg.pid);
|
|
3528
|
+
}
|
|
3529
|
+
socket.write(
|
|
3530
|
+
serializeIpc({
|
|
3531
|
+
type: "session_create_response",
|
|
3532
|
+
sessionId: session.id,
|
|
3533
|
+
hook: createHookContext(session.id, provider)
|
|
3534
|
+
})
|
|
3535
|
+
);
|
|
3536
|
+
serviceLogger.info(
|
|
3537
|
+
{ sessionId: session.id, mode: "pty", provider },
|
|
3538
|
+
"PTY session created"
|
|
3539
|
+
);
|
|
3540
|
+
break;
|
|
3541
|
+
}
|
|
3542
|
+
case "service_status_request": {
|
|
3543
|
+
const relayStatus = relayConnection.getStatus();
|
|
3544
|
+
const sessions = sessionManager.listSessions();
|
|
3545
|
+
socket.write(
|
|
3546
|
+
serializeIpc({
|
|
3547
|
+
type: "service_status_response",
|
|
3548
|
+
config,
|
|
3549
|
+
relay: relayStatus,
|
|
3550
|
+
sessions: sessions.map((s) => ({
|
|
3551
|
+
id: s.id,
|
|
3552
|
+
mode: s.mode,
|
|
3553
|
+
provider: s.provider,
|
|
3554
|
+
state: s.state,
|
|
3555
|
+
createdAt: new Date(s.createdAt).toISOString(),
|
|
3556
|
+
...s.name !== void 0 ? { name: s.name } : {},
|
|
3557
|
+
hasWorker: workerRegistry.has(s.id)
|
|
3558
|
+
}))
|
|
3559
|
+
})
|
|
3560
|
+
);
|
|
3561
|
+
break;
|
|
3562
|
+
}
|
|
3563
|
+
case "pty_title_change": {
|
|
3564
|
+
if (!sessionManager.getSession(msg.sessionId)) break;
|
|
3565
|
+
relayConnection.sendRaw(
|
|
3566
|
+
JSON.stringify({
|
|
3567
|
+
type: "terminal_title",
|
|
3568
|
+
sessionId: msg.sessionId,
|
|
3569
|
+
title: msg.title
|
|
3570
|
+
})
|
|
3571
|
+
);
|
|
3572
|
+
break;
|
|
3573
|
+
}
|
|
3574
|
+
case "pty_semantic_event": {
|
|
3575
|
+
if (!sessionManager.getSession(msg.sessionId)) break;
|
|
3576
|
+
const logPayload = {
|
|
3577
|
+
sessionId: msg.sessionId,
|
|
3578
|
+
state: msg.state,
|
|
3579
|
+
...msg.title !== void 0 ? { title: msg.title } : {},
|
|
3580
|
+
...msg.tool !== void 0 ? { tool: msg.tool } : {}
|
|
3581
|
+
};
|
|
3582
|
+
if (msg.state === "approval_wait" || msg.state === "turn_complete") {
|
|
3583
|
+
serviceLogger.info(logPayload, "PTY semantic event received");
|
|
3584
|
+
} else {
|
|
3585
|
+
serviceLogger.debug(logPayload, "PTY semantic event received");
|
|
3586
|
+
}
|
|
3587
|
+
if (msg.state === "approval_wait") {
|
|
3588
|
+
changeSessionState(
|
|
3589
|
+
sessionManager,
|
|
3590
|
+
relayConnection,
|
|
3591
|
+
msg.sessionId,
|
|
3592
|
+
SessionState.WAITING_APPROVAL
|
|
3593
|
+
);
|
|
3594
|
+
} else if (msg.state === "working" || msg.state === "mid_pause") {
|
|
3595
|
+
const session = sessionManager.getSession(msg.sessionId);
|
|
3596
|
+
const pendingApprovals = permissionBroker.listSession(msg.sessionId);
|
|
3597
|
+
if (shouldPromotePtyActivityToWorking(session, pendingApprovals.length)) {
|
|
3598
|
+
changeSessionState(
|
|
3599
|
+
sessionManager,
|
|
3600
|
+
relayConnection,
|
|
3601
|
+
msg.sessionId,
|
|
3602
|
+
SessionState.WORKING
|
|
3603
|
+
);
|
|
3604
|
+
} else if (session?.state === SessionState.WAITING_APPROVAL) {
|
|
3605
|
+
serviceLogger.debug(
|
|
3606
|
+
{
|
|
3607
|
+
sessionId: msg.sessionId,
|
|
3608
|
+
ptyState: msg.state,
|
|
3609
|
+
pendingApprovals: pendingApprovals.length
|
|
3610
|
+
},
|
|
3611
|
+
"PTY working signal ignored while permission approval is pending"
|
|
3612
|
+
);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
if (msg.state === "turn_complete") {
|
|
3616
|
+
resolveInterruptedApprovals2(msg.sessionId);
|
|
3617
|
+
const session = sessionManager.getSession(msg.sessionId);
|
|
3618
|
+
const transitions = resolvePtySemanticSessionTransitions(session?.state, msg.state);
|
|
3619
|
+
for (const next of transitions) {
|
|
3620
|
+
changeSessionState(sessionManager, relayConnection, msg.sessionId, next);
|
|
3621
|
+
}
|
|
3622
|
+
emitAgentStatus(msg.sessionId, "idle");
|
|
3623
|
+
}
|
|
3624
|
+
relayConnection.sendRaw(
|
|
3625
|
+
JSON.stringify({
|
|
3626
|
+
type: "pty_state",
|
|
3627
|
+
sessionId: msg.sessionId,
|
|
3628
|
+
payload: {
|
|
3629
|
+
state: msg.state,
|
|
3630
|
+
...msg.title !== void 0 ? { title: msg.title } : {},
|
|
3631
|
+
...msg.tool !== void 0 ? { tool: msg.tool } : {}
|
|
3632
|
+
}
|
|
3633
|
+
})
|
|
3634
|
+
);
|
|
3635
|
+
break;
|
|
3636
|
+
}
|
|
3637
|
+
case "pty_resize": {
|
|
3638
|
+
if (!sessionManager.getSession(msg.sessionId)) break;
|
|
3639
|
+
relayConnection.sendRaw(
|
|
3640
|
+
JSON.stringify({
|
|
3641
|
+
type: "terminal_resize",
|
|
3642
|
+
sessionId: msg.sessionId,
|
|
3643
|
+
cols: msg.cols,
|
|
3644
|
+
rows: msg.rows
|
|
3645
|
+
})
|
|
3646
|
+
);
|
|
3647
|
+
break;
|
|
3648
|
+
}
|
|
3649
|
+
case "session_terminate_request": {
|
|
3650
|
+
const result = terminateSessionByOwnership(
|
|
3651
|
+
{
|
|
3652
|
+
sessionManager,
|
|
3653
|
+
workerRegistry,
|
|
3654
|
+
controlHandlers,
|
|
3655
|
+
terminalSockets,
|
|
3656
|
+
hostedPtyRegistry,
|
|
3657
|
+
agentStatusRegistry
|
|
3658
|
+
},
|
|
3659
|
+
msg.sessionId
|
|
3660
|
+
);
|
|
3661
|
+
socket.write(
|
|
3662
|
+
serializeIpc({
|
|
3663
|
+
type: "session_terminate_response",
|
|
3664
|
+
sessionId: msg.sessionId,
|
|
3665
|
+
success: result.success
|
|
3666
|
+
})
|
|
3667
|
+
);
|
|
3668
|
+
serviceLogger.info(
|
|
3669
|
+
{ sessionId: msg.sessionId, success: result.success, action: result.action },
|
|
3670
|
+
"Session termination request handled"
|
|
3671
|
+
);
|
|
3672
|
+
break;
|
|
3673
|
+
}
|
|
3674
|
+
case "pty_register": {
|
|
3675
|
+
if (!sessionManager.getSession(msg.sessionId)) {
|
|
3676
|
+
serviceLogger.warn(
|
|
3677
|
+
{ sessionId: msg.sessionId },
|
|
3678
|
+
"PTY register ignored: session missing"
|
|
3679
|
+
);
|
|
3680
|
+
break;
|
|
3681
|
+
}
|
|
3682
|
+
sessionManager.setPid(msg.sessionId, msg.pid);
|
|
3683
|
+
terminalSockets.set(msg.sessionId, socket);
|
|
3684
|
+
socket.write(
|
|
3685
|
+
serializeIpc({
|
|
3686
|
+
type: "bridge_status",
|
|
3687
|
+
connected: relayConnection.getStatus().connected
|
|
3688
|
+
})
|
|
3689
|
+
);
|
|
3690
|
+
const session = sessionManager.getSession(msg.sessionId);
|
|
3691
|
+
if (session) {
|
|
3692
|
+
broadcastSessionSync(relayConnection, session);
|
|
3693
|
+
}
|
|
3694
|
+
broadcastSessionList(relayConnection, sessionManager);
|
|
3695
|
+
serviceLogger.info({ sessionId: msg.sessionId }, "PTY session registered");
|
|
3696
|
+
break;
|
|
3697
|
+
}
|
|
3698
|
+
case "pty_deregister": {
|
|
3699
|
+
relayConnection.sendRaw(
|
|
3700
|
+
JSON.stringify({
|
|
3701
|
+
type: "pty_state",
|
|
3702
|
+
sessionId: msg.sessionId,
|
|
3703
|
+
payload: { state: "turn_complete" }
|
|
3704
|
+
})
|
|
3705
|
+
);
|
|
3706
|
+
sessionManager.terminateSession(msg.sessionId);
|
|
3707
|
+
terminalSockets.delete(msg.sessionId);
|
|
3708
|
+
controlHandlers.cleanup(msg.sessionId);
|
|
3709
|
+
agentStatusRegistry.delete(msg.sessionId);
|
|
3710
|
+
broadcastSessionList(relayConnection, sessionManager);
|
|
3711
|
+
serviceLogger.info({ sessionId: msg.sessionId }, "PTY session deregistered");
|
|
3712
|
+
break;
|
|
3713
|
+
}
|
|
3714
|
+
case "pty_input": {
|
|
3715
|
+
if (!sessionManager.getSession(msg.sessionId)) break;
|
|
3716
|
+
const targetSocket = terminalSockets.get(msg.sessionId);
|
|
3717
|
+
if (hostedPtyRegistry.write(msg.sessionId, msg.data)) {
|
|
3718
|
+
break;
|
|
3719
|
+
}
|
|
3720
|
+
if (targetSocket?.writable) {
|
|
3721
|
+
targetSocket.write(
|
|
3722
|
+
serializeIpc({
|
|
3723
|
+
type: "pty_input",
|
|
3724
|
+
sessionId: msg.sessionId,
|
|
3725
|
+
data: msg.data
|
|
3726
|
+
})
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
break;
|
|
3730
|
+
}
|
|
3731
|
+
case "session_status_update": {
|
|
3732
|
+
changeSessionState(sessionManager, relayConnection, msg.sessionId, msg.state);
|
|
3733
|
+
break;
|
|
3734
|
+
}
|
|
3735
|
+
case "pty_snapshot": {
|
|
3736
|
+
if (!sessionManager.getSession(msg.sessionId)) break;
|
|
3737
|
+
relayConnection.sendRaw(
|
|
3738
|
+
JSON.stringify({
|
|
3739
|
+
type: "session_snapshot",
|
|
3740
|
+
sessionId: msg.sessionId,
|
|
3741
|
+
cols: msg.cols,
|
|
3742
|
+
rows: msg.rows,
|
|
3743
|
+
data: msg.data,
|
|
3744
|
+
outputSeq: msg.outputSeq,
|
|
3745
|
+
requestId: msg.requestId
|
|
3746
|
+
})
|
|
3747
|
+
);
|
|
3748
|
+
serviceLogger.info(
|
|
3749
|
+
{ sessionId: msg.sessionId, cols: msg.cols, rows: msg.rows },
|
|
3750
|
+
"Session snapshot forwarded to relay"
|
|
3751
|
+
);
|
|
3752
|
+
break;
|
|
3753
|
+
}
|
|
3754
|
+
default: {
|
|
3755
|
+
serviceLogger.warn({ type: msg.type }, "Unhandled IPC message type");
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
},
|
|
3759
|
+
(sessionId, data, outputSeq) => {
|
|
3760
|
+
if (!sessionManager.getSession(sessionId)) return;
|
|
3761
|
+
const sessionIdBuf = Buffer.from(sessionId, "utf-8");
|
|
3762
|
+
const wsFrame = Buffer.alloc(1 + sessionIdBuf.length + 4 + data.length);
|
|
3763
|
+
wsFrame[0] = sessionIdBuf.length;
|
|
3764
|
+
sessionIdBuf.copy(wsFrame, 1);
|
|
3765
|
+
wsFrame.writeUInt32LE(outputSeq, 1 + sessionIdBuf.length);
|
|
3766
|
+
data.copy(wsFrame, 1 + sessionIdBuf.length + 4);
|
|
3767
|
+
relayConnection.sendBinary(wsFrame);
|
|
3768
|
+
}
|
|
3769
|
+
);
|
|
3770
|
+
socket.on("close", () => {
|
|
3771
|
+
for (const [sessionId, terminalSocket] of terminalSockets) {
|
|
3772
|
+
if (terminalSocket === socket) {
|
|
3773
|
+
terminalSockets.delete(sessionId);
|
|
3774
|
+
const session = sessionManager.getSession(sessionId);
|
|
3775
|
+
if (!session) {
|
|
3776
|
+
serviceLogger.info({ sessionId }, "Terminal socket closed, session already cleaned");
|
|
3777
|
+
continue;
|
|
3778
|
+
}
|
|
3779
|
+
if (session.mode === "pty" && session.pid && isProcessAlive(session.pid)) {
|
|
3780
|
+
serviceLogger.info(
|
|
3781
|
+
{ sessionId, pid: session.pid },
|
|
3782
|
+
"Terminal socket closed but process alive, skipping cleanup"
|
|
3783
|
+
);
|
|
3784
|
+
continue;
|
|
3785
|
+
}
|
|
3786
|
+
relayConnection.sendRaw(
|
|
3787
|
+
JSON.stringify({
|
|
3788
|
+
type: "pty_state",
|
|
3789
|
+
sessionId,
|
|
3790
|
+
payload: { state: "turn_complete" }
|
|
3791
|
+
})
|
|
3792
|
+
);
|
|
3793
|
+
sessionManager.terminateSession(sessionId);
|
|
3794
|
+
controlHandlers.cleanup(sessionId);
|
|
3795
|
+
agentStatusRegistry.delete(sessionId);
|
|
3796
|
+
broadcastSessionList(relayConnection, sessionManager);
|
|
3797
|
+
serviceLogger.info(
|
|
3798
|
+
{ sessionId },
|
|
3799
|
+
"PTY session cleaned up on socket close (crash fallback)"
|
|
3800
|
+
);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
});
|
|
3804
|
+
socket.on("error", (err) => {
|
|
3805
|
+
serviceLogger.warn({ error: String(err) }, "Client socket error");
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
// src/serve/hook-registry.ts
|
|
3810
|
+
import { createHash, randomBytes } from "crypto";
|
|
3811
|
+
function hashToken(token) {
|
|
3812
|
+
return createHash("sha256").update(token).digest("hex");
|
|
3813
|
+
}
|
|
3814
|
+
function randomSecret() {
|
|
3815
|
+
return randomBytes(32).toString("base64url");
|
|
3816
|
+
}
|
|
3817
|
+
var HookRegistry = class {
|
|
3818
|
+
bindingsBySession = /* @__PURE__ */ new Map();
|
|
3819
|
+
registerSession(sessionId, provider, options = {}) {
|
|
3820
|
+
const now = options.now ?? Date.now();
|
|
3821
|
+
const token = randomSecret();
|
|
3822
|
+
const marker = randomSecret();
|
|
3823
|
+
this.bindingsBySession.set(sessionId, {
|
|
3824
|
+
sessionId,
|
|
3825
|
+
provider,
|
|
3826
|
+
marker,
|
|
3827
|
+
tokenHash: hashToken(token),
|
|
3828
|
+
createdAt: now,
|
|
3829
|
+
...options.ttlMs ? { expiresAt: now + options.ttlMs } : {}
|
|
3830
|
+
});
|
|
3831
|
+
return { sessionId, provider, marker, token };
|
|
3832
|
+
}
|
|
3833
|
+
verify(options) {
|
|
3834
|
+
const binding = this.bindingsBySession.get(options.sessionId);
|
|
3835
|
+
if (!binding) return null;
|
|
3836
|
+
if (options.provider && binding.provider !== options.provider) return null;
|
|
3837
|
+
if (binding.marker !== options.marker) return null;
|
|
3838
|
+
if (binding.tokenHash !== hashToken(options.token)) return null;
|
|
3839
|
+
if (binding.expiresAt && (options.now ?? Date.now()) > binding.expiresAt) return null;
|
|
3840
|
+
return binding;
|
|
3841
|
+
}
|
|
3842
|
+
getSession(sessionId) {
|
|
3843
|
+
return this.bindingsBySession.get(sessionId) ?? null;
|
|
3844
|
+
}
|
|
3845
|
+
unregisterSession(sessionId) {
|
|
3846
|
+
this.bindingsBySession.delete(sessionId);
|
|
3847
|
+
}
|
|
3848
|
+
};
|
|
3849
|
+
|
|
3850
|
+
// src/serve/hook-server.ts
|
|
3851
|
+
import { createServer } from "http";
|
|
3852
|
+
function getBearerToken(req) {
|
|
3853
|
+
const header = req.headers.authorization;
|
|
3854
|
+
if (!header?.startsWith("Bearer ")) return null;
|
|
3855
|
+
return header.slice("Bearer ".length).trim() || null;
|
|
3856
|
+
}
|
|
3857
|
+
function asProvider(value) {
|
|
3858
|
+
return value === "claude" || value === "codex" ? value : null;
|
|
3859
|
+
}
|
|
3860
|
+
function asRecord(value) {
|
|
3861
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3862
|
+
}
|
|
3863
|
+
var HookServer = class {
|
|
3864
|
+
constructor(options) {
|
|
3865
|
+
this.options = options;
|
|
3866
|
+
this.host = options.host ?? "127.0.0.1";
|
|
3867
|
+
this.maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
|
|
3868
|
+
}
|
|
3869
|
+
options;
|
|
3870
|
+
server = null;
|
|
3871
|
+
host;
|
|
3872
|
+
maxBodyBytes;
|
|
3873
|
+
start() {
|
|
3874
|
+
if (this.server) return Promise.resolve();
|
|
3875
|
+
this.server = createServer((req, res) => {
|
|
3876
|
+
this.handle(req, res).catch((err) => {
|
|
3877
|
+
serviceLogger.error({ err: String(err) }, "Hook request failed");
|
|
3878
|
+
this.writeJson(res, 500, { error: "internal_error" });
|
|
3879
|
+
});
|
|
3880
|
+
});
|
|
3881
|
+
return new Promise((resolve, reject) => {
|
|
3882
|
+
const onError = (err) => {
|
|
3883
|
+
this.server?.off("listening", onListening);
|
|
3884
|
+
reject(err);
|
|
3885
|
+
};
|
|
3886
|
+
const onListening = () => {
|
|
3887
|
+
this.server?.off("error", onError);
|
|
3888
|
+
serviceLogger.info({ host: this.host, port: this.options.port }, "Hook server listening");
|
|
3889
|
+
resolve();
|
|
3890
|
+
};
|
|
3891
|
+
this.server.once("error", onError);
|
|
3892
|
+
this.server.once("listening", onListening);
|
|
3893
|
+
this.server.listen(this.options.port, this.host);
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
close() {
|
|
3897
|
+
if (!this.server) return Promise.resolve();
|
|
3898
|
+
const server = this.server;
|
|
3899
|
+
this.server = null;
|
|
3900
|
+
return new Promise((resolve, reject) => {
|
|
3901
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
getListeningPort() {
|
|
3905
|
+
const address = this.server?.address();
|
|
3906
|
+
if (!address || typeof address === "string") return null;
|
|
3907
|
+
return address.port;
|
|
3908
|
+
}
|
|
3909
|
+
async handle(req, res) {
|
|
3910
|
+
if (req.method !== "POST" || req.url !== "/hook") {
|
|
3911
|
+
this.writeJson(res, 404, { error: "not_found" });
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
const token = getBearerToken(req);
|
|
3915
|
+
if (!token) {
|
|
3916
|
+
this.writeJson(res, 401, { error: "missing_token" });
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
const body = await this.readBody(req);
|
|
3920
|
+
const parsed = JSON.parse(body);
|
|
3921
|
+
const provider = asProvider(parsed.provider);
|
|
3922
|
+
if (typeof parsed.sessionId !== "string" || typeof parsed.marker !== "string" || typeof parsed.event !== "string" || !provider) {
|
|
3923
|
+
this.writeJson(res, 400, { error: "invalid_hook_payload" });
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
const binding = this.options.registry.verify({
|
|
3927
|
+
sessionId: parsed.sessionId,
|
|
3928
|
+
marker: parsed.marker,
|
|
3929
|
+
token,
|
|
3930
|
+
provider
|
|
3931
|
+
});
|
|
3932
|
+
if (!binding) {
|
|
3933
|
+
this.writeJson(res, 403, { error: "invalid_hook_credentials" });
|
|
3934
|
+
return;
|
|
3935
|
+
}
|
|
3936
|
+
if (this.options.isSessionActive && !this.options.isSessionActive(binding.sessionId)) {
|
|
3937
|
+
serviceLogger.info(
|
|
3938
|
+
{ sessionId: binding.sessionId, provider: binding.provider, event: parsed.event },
|
|
3939
|
+
"Provider hook ignored for inactive session"
|
|
3940
|
+
);
|
|
3941
|
+
this.writeProviderResponse(res, this.toNeutralProviderResponse(provider, parsed.event));
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
const payload = asRecord(parsed.payload);
|
|
3945
|
+
const requestId = typeof parsed.requestId === "string" ? parsed.requestId : typeof payload.tool_use_id === "string" ? payload.tool_use_id : void 0;
|
|
3946
|
+
const event = {
|
|
3947
|
+
sessionId: binding.sessionId,
|
|
3948
|
+
provider: binding.provider,
|
|
3949
|
+
event: parsed.event,
|
|
3950
|
+
...requestId !== void 0 ? { requestId } : {},
|
|
3951
|
+
payload
|
|
3952
|
+
};
|
|
3953
|
+
if (event.event === "PermissionRequest") {
|
|
3954
|
+
await this.handlePermissionRequest(event, res);
|
|
3955
|
+
return;
|
|
3956
|
+
}
|
|
3957
|
+
this.options.onEvent?.(event);
|
|
3958
|
+
this.writeProviderResponse(res, this.toNeutralProviderResponse(event.provider, event.event));
|
|
3959
|
+
}
|
|
3960
|
+
async handlePermissionRequest(event, res) {
|
|
3961
|
+
const requestId = event.requestId ?? (typeof event.payload.tool_use_id === "string" ? event.payload.tool_use_id : void 0) ?? `${event.sessionId}:${Date.now()}`;
|
|
3962
|
+
const toolName = typeof event.payload.toolName === "string" ? event.payload.toolName : typeof event.payload.tool_name === "string" ? event.payload.tool_name : "unknown";
|
|
3963
|
+
const input = asRecord(event.payload.input ?? event.payload.tool_input);
|
|
3964
|
+
this.options.onEvent?.({ ...event, requestId });
|
|
3965
|
+
const decision = await this.options.permissionBroker.request({
|
|
3966
|
+
requestId,
|
|
3967
|
+
sessionId: event.sessionId,
|
|
3968
|
+
provider: event.provider,
|
|
3969
|
+
toolName,
|
|
3970
|
+
input
|
|
3971
|
+
});
|
|
3972
|
+
this.writeJson(res, 200, this.toProviderDecision(event.event, decision));
|
|
3973
|
+
}
|
|
3974
|
+
toProviderDecision(eventName, decision) {
|
|
3975
|
+
if (eventName === "PreToolUse") {
|
|
3976
|
+
return {
|
|
3977
|
+
hookSpecificOutput: {
|
|
3978
|
+
hookEventName: "PreToolUse",
|
|
3979
|
+
permissionDecision: decision.behavior,
|
|
3980
|
+
...decision.message ? { permissionDecisionReason: decision.message } : {}
|
|
3981
|
+
}
|
|
3982
|
+
};
|
|
3983
|
+
}
|
|
3984
|
+
return {
|
|
3985
|
+
hookSpecificOutput: {
|
|
3986
|
+
hookEventName: "PermissionRequest",
|
|
3987
|
+
decision
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
toNeutralProviderResponse(provider, eventName) {
|
|
3992
|
+
if (eventName === "PreToolUse") {
|
|
3993
|
+
if (provider === "codex") {
|
|
3994
|
+
return null;
|
|
3995
|
+
}
|
|
3996
|
+
return {
|
|
3997
|
+
hookSpecificOutput: {
|
|
3998
|
+
hookEventName: "PreToolUse",
|
|
3999
|
+
permissionDecision: "defer"
|
|
4000
|
+
}
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
return null;
|
|
4004
|
+
}
|
|
4005
|
+
writeProviderResponse(res, payload) {
|
|
4006
|
+
if (res.headersSent) return;
|
|
4007
|
+
if (payload === null) {
|
|
4008
|
+
res.writeHead(200);
|
|
4009
|
+
res.end();
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
this.writeJson(res, 200, payload);
|
|
4013
|
+
}
|
|
4014
|
+
readBody(req) {
|
|
4015
|
+
return new Promise((resolve, reject) => {
|
|
4016
|
+
let body = "";
|
|
4017
|
+
let size = 0;
|
|
4018
|
+
req.setEncoding("utf8");
|
|
4019
|
+
req.on("data", (chunk) => {
|
|
4020
|
+
size += Buffer.byteLength(chunk);
|
|
4021
|
+
if (size > this.maxBodyBytes) {
|
|
4022
|
+
reject(new Error("hook body too large"));
|
|
4023
|
+
req.destroy();
|
|
4024
|
+
return;
|
|
4025
|
+
}
|
|
4026
|
+
body += chunk;
|
|
4027
|
+
});
|
|
4028
|
+
req.on("end", () => resolve(body));
|
|
4029
|
+
req.on("error", reject);
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
writeJson(res, statusCode, payload) {
|
|
4033
|
+
if (res.headersSent) return;
|
|
4034
|
+
res.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
4035
|
+
res.end(JSON.stringify(payload));
|
|
4036
|
+
}
|
|
4037
|
+
};
|
|
4038
|
+
|
|
4039
|
+
// src/serve/provider-hook-runtime.ts
|
|
4040
|
+
async function createProviderHookRuntime(options) {
|
|
4041
|
+
const hookRegistry = new HookRegistry();
|
|
4042
|
+
const hookEventRouter = new HookEventRouter({
|
|
4043
|
+
relayConnection: options.relayConnection,
|
|
4044
|
+
agentStatusRegistry: options.agentStatusRegistry,
|
|
4045
|
+
changeSessionState: options.changeSessionState
|
|
4046
|
+
});
|
|
4047
|
+
const port = options.hookPort ?? 17654;
|
|
4048
|
+
const hookServer = new HookServer({
|
|
4049
|
+
port,
|
|
4050
|
+
registry: hookRegistry,
|
|
4051
|
+
permissionBroker: options.permissionBroker,
|
|
4052
|
+
isSessionActive: (sessionId) => !!options.sessionManager.getSession(sessionId),
|
|
4053
|
+
onEvent: (event) => {
|
|
4054
|
+
serviceLogger.info(
|
|
4055
|
+
{
|
|
4056
|
+
sessionId: event.sessionId,
|
|
4057
|
+
provider: event.provider,
|
|
4058
|
+
event: event.event,
|
|
4059
|
+
requestId: event.requestId
|
|
4060
|
+
},
|
|
4061
|
+
"Provider hook event received"
|
|
4062
|
+
);
|
|
4063
|
+
hookEventRouter.handle(event);
|
|
4064
|
+
}
|
|
4065
|
+
});
|
|
4066
|
+
try {
|
|
4067
|
+
await hookServer.start();
|
|
4068
|
+
} catch (err) {
|
|
4069
|
+
const msg = `Failed to start hook server on 127.0.0.1:${port}: ${String(err)}`;
|
|
4070
|
+
serviceLogger.error(msg);
|
|
4071
|
+
console.error(msg);
|
|
4072
|
+
process.exit(1);
|
|
4073
|
+
}
|
|
4074
|
+
const hookUrl = `http://127.0.0.1:${hookServer.getListeningPort() ?? port}/hook`;
|
|
4075
|
+
const createHookContext = (sessionId, provider) => {
|
|
4076
|
+
const credentials = hookRegistry.registerSession(sessionId, provider);
|
|
4077
|
+
return {
|
|
4078
|
+
provider,
|
|
4079
|
+
sessionId,
|
|
4080
|
+
hookUrl,
|
|
4081
|
+
marker: credentials.marker,
|
|
4082
|
+
token: credentials.token
|
|
4083
|
+
};
|
|
4084
|
+
};
|
|
4085
|
+
return {
|
|
4086
|
+
hookRegistry,
|
|
4087
|
+
hookEventRouter,
|
|
4088
|
+
hookServer,
|
|
4089
|
+
createHookContext
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// src/serve.ts
|
|
4094
|
+
function resolveInterruptedApprovals(permissionBroker, hookEventRouter, relay, sessionId) {
|
|
4095
|
+
const approvals = permissionBroker.listSession(sessionId);
|
|
4096
|
+
if (approvals.length === 0) return;
|
|
4097
|
+
const message = "Permission request was interrupted in the PTY.";
|
|
4098
|
+
for (const approval of approvals) {
|
|
4099
|
+
if (!permissionBroker.resolve(approval.requestId, { behavior: "deny", message })) continue;
|
|
4100
|
+
hookEventRouter.onPermissionResolved(
|
|
4101
|
+
approval.sessionId,
|
|
4102
|
+
approval.provider,
|
|
4103
|
+
approval.requestId,
|
|
4104
|
+
"deny",
|
|
4105
|
+
{ toolName: approval.toolName, toolInput: approval.input }
|
|
4106
|
+
);
|
|
4107
|
+
relay.sendRaw(
|
|
4108
|
+
JSON.stringify({
|
|
4109
|
+
type: "permission_decision_result",
|
|
4110
|
+
sessionId: approval.sessionId,
|
|
4111
|
+
requestId: approval.requestId,
|
|
4112
|
+
outcome: "deny",
|
|
4113
|
+
delivered: true,
|
|
4114
|
+
message
|
|
4115
|
+
})
|
|
4116
|
+
);
|
|
4117
|
+
}
|
|
4118
|
+
serviceLogger.info(
|
|
4119
|
+
{ sessionId, count: approvals.length },
|
|
4120
|
+
"Pending approvals cleared after PTY interruption"
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
function parseServiceOptions(argv) {
|
|
4124
|
+
const options = {};
|
|
4125
|
+
for (let i = 0; i < argv.length; i++) {
|
|
4126
|
+
const arg = argv[i];
|
|
4127
|
+
if (arg === "--env") {
|
|
4128
|
+
const envName = argv[i + 1];
|
|
4129
|
+
if (!envName || envName.startsWith("-")) {
|
|
4130
|
+
throw new Error("Missing value for --env");
|
|
4131
|
+
}
|
|
4132
|
+
options.envName = envName;
|
|
4133
|
+
i++;
|
|
4134
|
+
continue;
|
|
4135
|
+
}
|
|
4136
|
+
if (arg.startsWith("--env=")) {
|
|
4137
|
+
const envName = arg.slice("--env=".length);
|
|
4138
|
+
if (!envName) throw new Error("Missing value for --env");
|
|
4139
|
+
options.envName = envName;
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
return options;
|
|
4143
|
+
}
|
|
4144
|
+
async function startService(options) {
|
|
4145
|
+
await cleanupStaleResources();
|
|
4146
|
+
try {
|
|
4147
|
+
unlinkSync3(STOPPED_PATH);
|
|
4148
|
+
} catch {
|
|
4149
|
+
}
|
|
4150
|
+
const permissionBroker = new PermissionBroker();
|
|
4151
|
+
const agentStatusRegistry = new AgentStatusRegistry();
|
|
4152
|
+
let unregisterHookSession = () => {
|
|
4153
|
+
};
|
|
4154
|
+
const sessionManager = new SessionManager({
|
|
4155
|
+
persistPath: SESSIONS_PATH,
|
|
4156
|
+
onSessionRemoved: (id, context) => {
|
|
4157
|
+
if (!context?.preserveProviderHooks) {
|
|
4158
|
+
unregisterHookSession(id);
|
|
4159
|
+
}
|
|
4160
|
+
permissionBroker.cleanupSession(id, "session removed");
|
|
4161
|
+
agentStatusRegistry.delete(id);
|
|
4162
|
+
const paths = sessionPaths(id);
|
|
4163
|
+
try {
|
|
4164
|
+
rmSync2(paths.dir, { recursive: true, force: true });
|
|
4165
|
+
} catch {
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
});
|
|
4169
|
+
sessionManager.startReaper();
|
|
4170
|
+
const terminalSockets = /* @__PURE__ */ new Map();
|
|
4171
|
+
const proxyName = getProxyName();
|
|
4172
|
+
let proxyConfig = loadConfig({ envName: options?.envName });
|
|
4173
|
+
const getProviderEnv = () => buildProviderEnv(proxyConfig, process.env);
|
|
4174
|
+
const getAgentCliSuggestions = () => proxyConfig.agentCliSuggestions;
|
|
4175
|
+
const setAgentCliPath = (provider, path) => {
|
|
4176
|
+
const field = provider === "claude" ? "claudeBin" : "codexBin";
|
|
4177
|
+
const existing = proxyConfig.agentCliSuggestions[provider] ?? [];
|
|
4178
|
+
proxyConfig = {
|
|
4179
|
+
...proxyConfig,
|
|
4180
|
+
[field]: path,
|
|
4181
|
+
agentCliSuggestions: {
|
|
4182
|
+
...proxyConfig.agentCliSuggestions,
|
|
4183
|
+
[provider]: [path, ...existing.filter((candidate) => candidate !== path)]
|
|
4184
|
+
},
|
|
4185
|
+
sources: {
|
|
4186
|
+
...proxyConfig.sources,
|
|
4187
|
+
[field]: "file"
|
|
4188
|
+
}
|
|
4189
|
+
};
|
|
4190
|
+
};
|
|
4191
|
+
const relayUrl = options?.relayUrl ?? proxyConfig.relayUrl;
|
|
4192
|
+
const relayToken = proxyConfig.relayToken;
|
|
4193
|
+
const statusConfig = {
|
|
4194
|
+
envName: proxyConfig.envName,
|
|
4195
|
+
envNameSource: proxyConfig.sources.envName,
|
|
4196
|
+
relayUrl,
|
|
4197
|
+
relayUrlSource: proxyConfig.sources.relayUrl,
|
|
4198
|
+
relayTokenSource: proxyConfig.sources.relayToken,
|
|
4199
|
+
hookPort: proxyConfig.hookPort ?? 17654,
|
|
4200
|
+
hookPortSource: proxyConfig.sources.hookPort
|
|
4201
|
+
};
|
|
4202
|
+
if (!relayUrl) {
|
|
4203
|
+
const msg = 'Relay URL is required. Set it via RELAY_URL or ~/.dev-anywhere/config.json {"defaultEnv":"local","envs":{"local":{"relayUrl":"ws://..."}}}.';
|
|
4204
|
+
serviceLogger.error(msg);
|
|
4205
|
+
console.error(msg);
|
|
4206
|
+
process.exit(1);
|
|
4207
|
+
}
|
|
4208
|
+
const relayConnection = new RelayConnection(relayUrl, { name: proxyName, token: relayToken });
|
|
4209
|
+
const relaySend = (data) => relayConnection.sendRaw(data);
|
|
4210
|
+
const controlHandlers = createControlMessageHandlers(relaySend, sessionManager);
|
|
4211
|
+
const observerChangeState = (sessionId, next) => changeSessionState(sessionManager, relayConnection, sessionId, next);
|
|
4212
|
+
const emitAgentStatus = (sessionId, phase) => {
|
|
4213
|
+
const session = sessionManager.getSession(sessionId);
|
|
4214
|
+
if (!session) return;
|
|
4215
|
+
const payload = {
|
|
4216
|
+
provider: session.provider,
|
|
4217
|
+
phase,
|
|
4218
|
+
seq: new SeqCounter(sessionId).next(),
|
|
4219
|
+
updatedAt: Date.now()
|
|
4220
|
+
};
|
|
4221
|
+
agentStatusRegistry.set(sessionId, payload);
|
|
4222
|
+
relayConnection.sendRaw(JSON.stringify({ type: "agent_status", sessionId, payload }));
|
|
4223
|
+
};
|
|
4224
|
+
const jsonObserver = new JsonObserver({
|
|
4225
|
+
changeSessionState: observerChangeState,
|
|
4226
|
+
emitAgentStatus
|
|
4227
|
+
});
|
|
4228
|
+
const hookRuntime = await createProviderHookRuntime({
|
|
4229
|
+
hookPort: proxyConfig.hookPort,
|
|
4230
|
+
permissionBroker,
|
|
4231
|
+
sessionManager,
|
|
4232
|
+
relayConnection,
|
|
4233
|
+
agentStatusRegistry,
|
|
4234
|
+
changeSessionState: observerChangeState
|
|
4235
|
+
});
|
|
4236
|
+
unregisterHookSession = (sessionId) => hookRuntime.hookRegistry.unregisterSession(sessionId);
|
|
4237
|
+
const workerRegistry = new WorkerRegistry({
|
|
4238
|
+
sessionManager,
|
|
4239
|
+
permissionBroker,
|
|
4240
|
+
relayConnection,
|
|
4241
|
+
jsonObserver,
|
|
4242
|
+
getProviderEnv
|
|
4243
|
+
});
|
|
4244
|
+
const hostedPtyRegistry = new HostedPtyRegistry({
|
|
4245
|
+
sessionManager,
|
|
4246
|
+
relayConnection,
|
|
4247
|
+
getProviderEnv,
|
|
4248
|
+
changeSessionState: observerChangeState,
|
|
4249
|
+
onTurnComplete: (sessionId) => {
|
|
4250
|
+
resolveInterruptedApprovals(
|
|
4251
|
+
permissionBroker,
|
|
4252
|
+
hookRuntime.hookEventRouter,
|
|
4253
|
+
relayConnection,
|
|
4254
|
+
sessionId
|
|
4255
|
+
);
|
|
4256
|
+
emitAgentStatus(sessionId, "idle");
|
|
4257
|
+
},
|
|
4258
|
+
onSessionClosed: (sessionId) => {
|
|
4259
|
+
controlHandlers.cleanup(sessionId);
|
|
4260
|
+
agentStatusRegistry.delete(sessionId);
|
|
4261
|
+
broadcastSessionList(relayConnection, sessionManager);
|
|
4262
|
+
}
|
|
4263
|
+
});
|
|
4264
|
+
relayConnection.connect();
|
|
4265
|
+
serviceLogger.info(
|
|
4266
|
+
{
|
|
4267
|
+
envName: proxyConfig.envName ?? "(single)",
|
|
4268
|
+
relayUrl,
|
|
4269
|
+
proxyName,
|
|
4270
|
+
tokenSet: !!relayToken,
|
|
4271
|
+
relayUrlSource: proxyConfig.sources.relayUrl
|
|
4272
|
+
},
|
|
4273
|
+
"Connecting to relay server"
|
|
4274
|
+
);
|
|
4275
|
+
const relayRouter = new RelayRouter({
|
|
4276
|
+
sessionManager,
|
|
4277
|
+
workerRegistry,
|
|
4278
|
+
controlHandlers,
|
|
4279
|
+
relayConnection,
|
|
4280
|
+
relaySend,
|
|
4281
|
+
terminalSockets,
|
|
4282
|
+
hostedPtyRegistry,
|
|
4283
|
+
broadcastSessionList: () => broadcastSessionList(relayConnection, sessionManager),
|
|
4284
|
+
broadcastSessionSync: (session) => broadcastSessionSync(relayConnection, session),
|
|
4285
|
+
jsonObserver,
|
|
4286
|
+
createHookContext: hookRuntime.createHookContext,
|
|
4287
|
+
cleanupHookContext: (sessionId) => hookRuntime.hookRegistry.unregisterSession(sessionId),
|
|
4288
|
+
permissionBroker,
|
|
4289
|
+
hookEventRouter: hookRuntime.hookEventRouter,
|
|
4290
|
+
agentStatusRegistry,
|
|
4291
|
+
envName: proxyConfig.envName,
|
|
4292
|
+
getProviderEnv,
|
|
4293
|
+
getAgentCliSuggestions,
|
|
4294
|
+
setAgentCliPath
|
|
4295
|
+
});
|
|
4296
|
+
relayConnection.on("message", (msg) => relayRouter.handle(msg));
|
|
4297
|
+
relayConnection.on("connected", () => {
|
|
4298
|
+
controlHandlers.reinitializeOnReconnect();
|
|
4299
|
+
broadcastBridgeStatus(true);
|
|
4300
|
+
});
|
|
4301
|
+
relayConnection.on("disconnected", () => {
|
|
4302
|
+
broadcastBridgeStatus(false);
|
|
4303
|
+
});
|
|
4304
|
+
function broadcastBridgeStatus(connected) {
|
|
4305
|
+
const msg = serializeIpc({ type: "bridge_status", connected });
|
|
4306
|
+
for (const [, sock] of terminalSockets) {
|
|
4307
|
+
if (sock.writable) sock.write(msg);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
await workerRegistry.reconnectAll();
|
|
4311
|
+
const server = createServer2((socket) => {
|
|
4312
|
+
handleTerminalConnection(socket, {
|
|
4313
|
+
sessionManager,
|
|
4314
|
+
workerRegistry,
|
|
4315
|
+
terminalSockets,
|
|
4316
|
+
hostedPtyRegistry,
|
|
4317
|
+
relayConnection,
|
|
4318
|
+
controlHandlers,
|
|
4319
|
+
agentStatusRegistry,
|
|
4320
|
+
permissionBroker,
|
|
4321
|
+
hookEventRouter: hookRuntime.hookEventRouter,
|
|
4322
|
+
createHookContext: hookRuntime.createHookContext,
|
|
4323
|
+
emitAgentStatus,
|
|
4324
|
+
config: statusConfig,
|
|
4325
|
+
resolveInterruptedApprovals: (sessionId) => resolveInterruptedApprovals(
|
|
4326
|
+
permissionBroker,
|
|
4327
|
+
hookRuntime.hookEventRouter,
|
|
4328
|
+
relayConnection,
|
|
4329
|
+
sessionId
|
|
4330
|
+
)
|
|
4331
|
+
});
|
|
4332
|
+
});
|
|
4333
|
+
server.listen(SOCK_PATH, () => {
|
|
4334
|
+
writeFileSync4(PID_PATH, String(process.pid));
|
|
4335
|
+
chmodSync(SOCK_PATH, 384);
|
|
4336
|
+
serviceLogger.info({ pid: process.pid, sock: SOCK_PATH }, "Service started");
|
|
4337
|
+
});
|
|
4338
|
+
async function shutdown() {
|
|
4339
|
+
serviceLogger.info("Shutting down service");
|
|
4340
|
+
sessionManager.stopReaper();
|
|
4341
|
+
await hookRuntime.hookServer.close();
|
|
4342
|
+
relayConnection.close();
|
|
4343
|
+
workerRegistry.destroyAll();
|
|
4344
|
+
hostedPtyRegistry.destroyAll();
|
|
4345
|
+
server.close();
|
|
4346
|
+
try {
|
|
4347
|
+
unlinkSync3(SOCK_PATH);
|
|
4348
|
+
} catch {
|
|
4349
|
+
}
|
|
4350
|
+
try {
|
|
4351
|
+
unlinkSync3(PID_PATH);
|
|
4352
|
+
} catch {
|
|
4353
|
+
}
|
|
4354
|
+
process.exit(0);
|
|
4355
|
+
}
|
|
4356
|
+
process.on("SIGTERM", () => {
|
|
4357
|
+
shutdown();
|
|
4358
|
+
});
|
|
4359
|
+
process.on("SIGINT", () => {
|
|
4360
|
+
shutdown();
|
|
4361
|
+
});
|
|
4362
|
+
}
|
|
4363
|
+
var isMainModule = process.argv[1] && (process.argv[1].endsWith("serve.js") || process.argv[1].endsWith("serve.ts"));
|
|
4364
|
+
if (isMainModule) {
|
|
4365
|
+
startService(parseServiceOptions(process.argv.slice(2))).catch((err) => {
|
|
4366
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4367
|
+
serviceLogger.error({ err: message }, "Service failed to start");
|
|
4368
|
+
console.error(message);
|
|
4369
|
+
process.exit(1);
|
|
4370
|
+
});
|
|
4371
|
+
}
|
|
4372
|
+
export {
|
|
4373
|
+
startService
|
|
4374
|
+
};
|
|
4375
|
+
//# sourceMappingURL=serve.js.map
|