Compare commits
586
Commits
stage/5510
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b7064c4c | ||
|
|
963e1bae8a | ||
|
|
294577a1d1 | ||
|
|
2afcdf2676 | ||
|
|
8fa1244279 | ||
|
|
b56dd423e7 | ||
|
|
68008e824b | ||
|
|
572c84005f | ||
|
|
b71c88f292 | ||
|
|
94fd2da830 | ||
|
|
e36f773891 | ||
|
|
e2ce040f1f | ||
|
|
e4d9b799e4 | ||
|
|
b1286878a4 | ||
|
|
52bcfa3cb9 | ||
|
|
529488057e | ||
|
|
c10bbd8e33 | ||
|
|
c8a443454c | ||
|
|
a8f3933fd0 | ||
|
|
00afa1eb31 | ||
|
|
8e9d41380a | ||
|
|
21f134056d | ||
|
|
c196306fcb | ||
|
|
cb5e1974d1 | ||
|
|
f7523f63a2 | ||
|
|
c076fe9e09 | ||
|
|
2ad70a2815 | ||
|
|
f79325498e | ||
|
|
88b8e7450a | ||
|
|
b7d81ce89e | ||
|
|
c5315bda6a | ||
|
|
f6f344063d | ||
|
|
80eb26301c | ||
|
|
ce483fc70f | ||
|
|
8cd1e544ed | ||
|
|
617881feab | ||
|
|
dc1d41db04 | ||
|
|
e34980d969 | ||
|
|
4f109aebbe | ||
|
|
4359ed4fee | ||
|
|
b0f1536f87 | ||
|
|
40a6a7696a | ||
|
|
f4569ade88 | ||
|
|
a6b187fc84 | ||
|
|
a71f6c6feb | ||
|
|
656575a400 | ||
|
|
71689c6ce7 | ||
|
|
a284d478d4 | ||
|
|
f08c8426e1 | ||
|
|
f74b8a7c86 | ||
|
|
b39bf1a113 | ||
|
|
394ab31eea | ||
|
|
47e394895d | ||
|
|
ae311e4377 | ||
|
|
a8f46e899e | ||
|
|
3e616ad455 | ||
|
|
989d7f3668 | ||
|
|
7150b8fb2b | ||
|
|
cfdfe4db8f | ||
|
|
351f664021 | ||
|
|
2477d992f4 | ||
|
|
b1a5ade137 | ||
|
|
842b2ac471 | ||
|
|
b1792e64e7 | ||
|
|
1607767408 | ||
|
|
f9443e17b2 | ||
|
|
ec63149e81 | ||
|
|
58df481bc5 | ||
|
|
1e17127f49 | ||
|
|
1f57afd84c | ||
|
|
f4fa4bf77d | ||
|
|
547ca6bb5e | ||
|
|
927c4fa1b6 | ||
|
|
0c6b07c52c | ||
|
|
f129de62e1 | ||
|
|
f990201f48 | ||
|
|
12df57498a | ||
|
|
ca653eca1d | ||
|
|
61f3cf7ed9 | ||
|
|
7d56234ea4 | ||
|
|
b52f5b1f0e | ||
|
|
049a1ef3de | ||
|
|
1f2c7abef0 | ||
|
|
4717dc97f2 | ||
|
|
ad620c3211 | ||
|
|
5efc635488 | ||
|
|
38462d7cbd | ||
|
|
649083eb7f | ||
|
|
3fae64a9e1 | ||
|
|
f765e8178d | ||
|
|
6cac3244d7 | ||
|
|
a1f8c51e6b | ||
|
|
10104dd82b | ||
|
|
2d391310f8 | ||
|
|
990a92a18e | ||
|
|
3599d1f896 | ||
|
|
de742f64ef | ||
|
|
cedc2ffc22 | ||
|
|
be5c071750 | ||
|
|
5df1470619 | ||
|
|
4311143c12 | ||
|
|
1ab000069c | ||
|
|
a946b616c8 | ||
|
|
2a804ae38d | ||
|
|
4b47cfa12d | ||
|
|
07f1a1405a | ||
|
|
edf6c6a686 | ||
|
|
2c0b4902d0 | ||
|
|
660d54af63 | ||
|
|
b810e5466f | ||
|
|
13dd97d60d | ||
|
|
641878bd88 | ||
|
|
d53587d77a | ||
|
|
e69d681e82 | ||
|
|
6e6893f3ae | ||
|
|
286c5e3336 | ||
|
|
e7841a5988 | ||
|
|
2e62c4f896 | ||
|
|
289b539bc4 | ||
|
|
0f76602790 | ||
|
|
1e00ef131b | ||
|
|
49c6f98f82 | ||
|
|
7ee8e43392 | ||
|
|
a8d10887df | ||
|
|
150c766875 | ||
|
|
94b70ce8b8 | ||
|
|
d52596a269 | ||
|
|
f8ade25db9 | ||
|
|
e025f0aca6 | ||
|
|
d510c2f508 | ||
|
|
6d248ecd0b | ||
|
|
df9187e5f8 | ||
|
|
51d4685b5f | ||
|
|
bee7b8a9e4 | ||
|
|
bd62772ca5 | ||
|
|
37bb44173e | ||
|
|
95107ceb94 | ||
|
|
a452665dd5 | ||
|
|
77ee0b07f9 | ||
|
|
aa99f6f7da | ||
|
|
e60323d5cd | ||
|
|
d7ed506d21 | ||
|
|
b28559f62c | ||
|
|
6a0defbc2f | ||
|
|
da5703d501 | ||
|
|
ff32048ac5 | ||
|
|
ec4bac9fc9 | ||
|
|
10e0a5f214 | ||
|
|
1a31003e9e | ||
|
|
e305d7154d | ||
|
|
da367cc692 | ||
|
|
c6a2c1b7d2 | ||
|
|
69e4fa3b98 | ||
|
|
9388c6495b | ||
|
|
259631e46b | ||
|
|
34f10d2545 | ||
|
|
59d2f295c8 | ||
|
|
edaa255fa3 | ||
|
|
743b757a77 | ||
|
|
d4e102fd38 | ||
|
|
bb5347e1cd | ||
|
|
e168b67e42 | ||
|
|
ebc631f53a | ||
|
|
134327aa41 | ||
|
|
3fac79bb59 | ||
|
|
b36fbdd633 | ||
|
|
7abd4773ca | ||
|
|
b33aff9d09 | ||
|
|
40ff74b6fc | ||
|
|
3672e0038b | ||
|
|
8c9f505fab | ||
|
|
5c21e2db90 | ||
|
|
fc528113f8 | ||
|
|
df82b46060 | ||
|
|
34c60bdf09 | ||
|
|
2e1d2917dd | ||
|
|
fd9f06d02d | ||
|
|
7fafeb7993 | ||
|
|
3b9c632a1f | ||
|
|
fcb28248a1 | ||
|
|
037a35abfd | ||
|
|
df65251d58 | ||
|
|
78b0425902 | ||
|
|
a00b02fccb | ||
|
|
00e4c5af6f | ||
|
|
15523b382d | ||
|
|
cfcc39194a | ||
|
|
43eb46c17f | ||
|
|
139b8d6703 | ||
|
|
05ab4173cf | ||
|
|
c54d3faca5 | ||
|
|
6c0f2c765c | ||
|
|
8066462fcc | ||
|
|
23df7bd8f2 | ||
|
|
5f81ed2b1c | ||
|
|
c27f3d67a8 | ||
|
|
46c734a721 | ||
|
|
65c185478c | ||
|
|
069a55db58 | ||
|
|
eee72f942e | ||
|
|
1f5e45527a | ||
|
|
0e5a0fadae | ||
|
|
45659362e7 | ||
|
|
0b5d2f3698 | ||
|
|
c6842e29ee | ||
|
|
4528279cb2 | ||
|
|
6bc16a68bc | ||
|
|
93814bbb76 | ||
|
|
dc9464f62c | ||
|
|
43bcdcf97f | ||
|
|
fc1dc3a52f | ||
|
|
7b38e9aceb | ||
|
|
8883a9994d | ||
|
|
4d03578192 | ||
|
|
963799158a | ||
|
|
af36f409b0 | ||
|
|
bec67d60ea | ||
|
|
20123b1268 | ||
|
|
1768826e15 | ||
|
|
63a562f6ed | ||
|
|
dcb515611d | ||
|
|
59023b784c | ||
|
|
cced4f3509 | ||
|
|
b1c2bea92e | ||
|
|
5e8937981d | ||
|
|
509cdb8dfc | ||
|
|
83e4903274 | ||
|
|
69d5647b60 | ||
|
|
747b5a6913 | ||
|
|
92c790ffe5 | ||
|
|
e88aff4388 | ||
|
|
f60805de01 | ||
|
|
f1115355a6 | ||
|
|
53d96fa7d7 | ||
|
|
d9c5b33449 | ||
|
|
d13e922120 | ||
|
|
8686528b1b | ||
|
|
dc3bf44e57 | ||
|
|
6086446018 | ||
|
|
71584418a8 | ||
|
|
1aab1e4105 | ||
|
|
7506884d29 | ||
|
|
37c59baf68 | ||
|
|
95f05c5147 | ||
|
|
108841ba31 | ||
|
|
1c4ea9c99a | ||
|
|
ae075ba188 | ||
|
|
dd44aa9830 | ||
|
|
155838f2c3 | ||
|
|
0dc9cc381c | ||
|
|
6d6560f286 | ||
|
|
91356b7c3f | ||
|
|
9b8a9e05d1 | ||
|
|
b58ea15e31 | ||
|
|
3d18a8ca5a | ||
|
|
69fb748f73 | ||
|
|
6261b626a7 | ||
|
|
fe8fc067aa | ||
|
|
3a95d82531 | ||
|
|
5a68684fc7 | ||
|
|
7a5e6bc27a | ||
|
|
7c1239dbe7 | ||
|
|
5b0246de61 | ||
|
|
07118df53c | ||
|
|
0b69346970 | ||
|
|
f556cf349e | ||
|
|
83b5ccb0fe | ||
|
|
e7ac9f09fd | ||
|
|
7d0bc4b6a5 | ||
|
|
d0ba72035e | ||
|
|
0f1ae89082 | ||
|
|
8841662768 | ||
|
|
1db353a1f0 | ||
|
|
e5ae65f510 | ||
|
|
4eb98889ba | ||
|
|
0fc0ebb2b7 | ||
|
|
fa54dbc9a2 | ||
|
|
0f6b9e674d | ||
|
|
8540583d95 | ||
|
|
b87d0179fc | ||
|
|
e510219e16 | ||
|
|
8228dbe4da | ||
|
|
f3e0e29815 | ||
|
|
fac3470668 | ||
|
|
636a543853 | ||
|
|
821f45dbb6 | ||
|
|
7b3f65bc42 | ||
|
|
3d1e2f1204 | ||
|
|
38db12d72a | ||
|
|
fc58592af7 | ||
|
|
91ceb60996 | ||
|
|
5defcfedfe | ||
|
|
7f03b52a00 | ||
|
|
8673ca7338 | ||
|
|
b88452f68c | ||
|
|
b5acb01833 | ||
|
|
8fb95b43a7 | ||
|
|
d73cea803e | ||
|
|
32b7f0a5e0 | ||
|
|
1ff002d170 | ||
|
|
fe34105b22 | ||
|
|
4e5703fc62 | ||
|
|
f5ff9b1465 | ||
|
|
a959a4c754 | ||
|
|
1d8e7fe664 | ||
|
|
483772b44b | ||
|
|
fb5a40f132 | ||
|
|
912ba43d54 | ||
|
|
54d60de1af | ||
|
|
ae8311915b | ||
|
|
f7887bfc87 | ||
|
|
e9f1e2a459 | ||
|
|
bf2f8f125f | ||
|
|
e4c3c728d9 | ||
|
|
eea4f03db8 | ||
|
|
3c7fbe3809 | ||
|
|
1f046e11b0 | ||
|
|
72cab65817 | ||
|
|
7c5add1903 | ||
|
|
b1703771d8 | ||
|
|
c45691141e | ||
|
|
650159d11f | ||
|
|
b59a9cadd1 | ||
|
|
cefcb21b29 | ||
|
|
222596cf17 | ||
|
|
5564b98b56 | ||
|
|
bf9b61ff3d | ||
|
|
f642dc6820 | ||
|
|
3ea7964a50 | ||
|
|
892e73f7d6 | ||
|
|
b77d4a2fb0 | ||
|
|
dbbccfaa2a | ||
|
|
90426570f2 | ||
|
|
903ace3f49 | ||
|
|
8b873c55eb | ||
|
|
cf7eb48e56 | ||
|
|
728ec10e66 | ||
|
|
bdee6ff7c2 | ||
|
|
2fdc919dcc | ||
|
|
c37d0568ae | ||
|
|
66277fbeb8 | ||
|
|
6e1254705b | ||
|
|
f50a80beb6 | ||
|
|
d92aeecfae | ||
|
|
bc6e01afad | ||
|
|
125a2f1b45 | ||
|
|
6f7fa623cb | ||
|
|
bd91b649f1 | ||
|
|
70c77095a7 | ||
|
|
192df903c8 | ||
|
|
cdc50e232a | ||
|
|
d42dae81a8 | ||
|
|
fa087c53d1 | ||
|
|
1af7eb5b16 | ||
|
|
04d5652699 | ||
|
|
0b839f7ef8 | ||
|
|
a662e37f89 | ||
|
|
c354c13c7d | ||
|
|
64c2c12df7 | ||
|
|
eb04bf5951 | ||
|
|
064e95a38a | ||
|
|
614b5c9f48 | ||
|
|
cabb467b36 | ||
|
|
44fca1e9a4 | ||
|
|
e7da197d4f | ||
|
|
51e3d09ad6 | ||
|
|
1480718767 | ||
|
|
84f9be2088 | ||
|
|
49437e20ab | ||
|
|
b284371bf2 | ||
|
|
0d37067123 | ||
|
|
2260a393f0 | ||
|
|
0f15416cb9 | ||
|
|
94aaac1e32 | ||
|
|
ea3163e234 | ||
|
|
eb9298ba91 | ||
|
|
2b0614ce35 | ||
|
|
7a94e34a6d | ||
|
|
bfff32baf5 | ||
|
|
7f27942393 | ||
|
|
21a8160338 | ||
|
|
c1fc3f2c79 | ||
|
|
80e3574eca | ||
|
|
5e5a41e79f | ||
|
|
fcef6ba1af | ||
|
|
e09ccbaac9 | ||
|
|
898a01b766 | ||
|
|
a818be1ed4 | ||
|
|
37547a7bd1 | ||
|
|
9d142e7868 | ||
|
|
b337609b38 | ||
|
|
41df2a7f83 | ||
|
|
707fef9f4f | ||
|
|
b296fa2ff7 | ||
|
|
eda1ddd6f3 | ||
|
|
c31d607a69 | ||
|
|
434c61a376 | ||
|
|
207ce37eed | ||
|
|
0f7f3b6740 | ||
|
|
7b91484d79 | ||
|
|
fd7666d5eb | ||
|
|
c7caadf2ba | ||
|
|
2801744fca | ||
|
|
8d936a86a3 | ||
|
|
218d586432 | ||
|
|
19b85e84a1 | ||
|
|
6c8d130a7b | ||
|
|
a4bd54ef71 | ||
|
|
afc3066ec0 | ||
|
|
dcf881f1e8 | ||
|
|
607d4af12c | ||
|
|
8298bc37fa | ||
|
|
693fbad5dc | ||
|
|
f003b2d159 | ||
|
|
cbe4502c46 | ||
|
|
5f3117f81e | ||
|
|
d018233e84 | ||
|
|
e6e9dde276 | ||
|
|
2c9e621b3d | ||
|
|
7fe2274ad0 | ||
|
|
a5b1898e20 | ||
|
|
2673fd24e2 | ||
|
|
874d62bc3a | ||
|
|
2d819d4d7f | ||
|
|
84ace41206 | ||
|
|
8ecf391340 | ||
|
|
3d6c17aba6 | ||
|
|
408d77a073 | ||
|
|
4c042dbfdf | ||
|
|
c35b0659fe | ||
|
|
23d66d45ce | ||
|
|
a260746d04 | ||
|
|
c6c0046ae1 | ||
|
|
5312f30899 | ||
|
|
8fc9a7e2de | ||
|
|
835589352d | ||
|
|
a3504778e9 | ||
|
|
791ca4d883 | ||
|
|
bbe5283e99 | ||
|
|
0e122174be | ||
|
|
705b75c16c | ||
|
|
03b11d1f8b | ||
|
|
c7b5a2cba5 | ||
|
|
372aa44694 | ||
|
|
a4491c568e | ||
|
|
aaebb62758 | ||
|
|
5a01ce11cd | ||
|
|
71ee457d7d | ||
|
|
c91fb55064 | ||
|
|
ed09ee6930 | ||
|
|
5cda58fc9b | ||
|
|
22b155e8d0 | ||
|
|
5f5ebd259e | ||
|
|
bb5e778154 | ||
|
|
1c64e52f6e | ||
|
|
d01cd8e51a | ||
|
|
7b384d4b70 | ||
|
|
88c4b9a6b8 | ||
|
|
6dd1fed8d3 | ||
|
|
f4f9157f8d | ||
|
|
ae5fd368db | ||
|
|
ef8f42c7b4 | ||
|
|
f6903c4f34 | ||
|
|
678a1ee926 | ||
|
|
5f8fd9d85a | ||
|
|
a261e46e7e | ||
|
|
95a5bc37e8 | ||
|
|
83c4013774 | ||
|
|
d0cceea54a | ||
|
|
737ba90440 | ||
|
|
55af555846 | ||
|
|
28428d3753 | ||
|
|
042804fd5a | ||
|
|
a4ea848f38 | ||
|
|
ab11795aa4 | ||
|
|
32581913ba | ||
|
|
3c6566b430 | ||
|
|
320789ae59 | ||
|
|
d85eb85c25 | ||
|
|
bf7d39c67a | ||
|
|
07b33505fd | ||
|
|
7ce03cbbdd | ||
|
|
d482a12345 | ||
|
|
620da54cde | ||
|
|
94a2091435 | ||
|
|
15e3e89c3c | ||
|
|
eb1484bede | ||
|
|
46623de2bd | ||
|
|
89023eb191 | ||
|
|
f52be26610 | ||
|
|
9c1c1d8063 | ||
|
|
c69f1a73dc | ||
|
|
fd1879418d | ||
|
|
c32b10ee8b | ||
|
|
cc5cf19710 | ||
|
|
a6a891f079 | ||
|
|
713418dec0 | ||
|
|
7a320bea3c | ||
|
|
a55096b81f | ||
|
|
5efc94f4b2 | ||
|
|
5c3ead61a2 | ||
|
|
dadffe0e02 | ||
|
|
ded12e48c5 | ||
|
|
2f17d1c74c | ||
|
|
dd7f6ac318 | ||
|
|
dbe58ffff5 | ||
|
|
0f010db5be | ||
|
|
1c385e6935 | ||
|
|
578e66c407 | ||
|
|
0a40159759 | ||
|
|
f989c4828d | ||
|
|
95a1647557 | ||
|
|
ab6f0855a5 | ||
|
|
86d3e6a571 | ||
|
|
127a57e0c5 | ||
|
|
cd04f5fcc2 | ||
|
|
3c53afeb12 | ||
|
|
1e591aef13 | ||
|
|
480de19710 | ||
|
|
16dfeeb3f0 | ||
|
|
0fa4b5f5ac | ||
|
|
bff58ffb31 | ||
|
|
edb69763a7 | ||
|
|
15e290dff0 | ||
|
|
b8f615e4bd | ||
|
|
4a7b851c8c | ||
|
|
9b0506d31d | ||
|
|
525edef5f7 | ||
|
|
66438f07bc | ||
|
|
920b48d709 | ||
|
|
5e447683f8 | ||
|
|
ad3770fd6e | ||
|
|
663aec682e | ||
|
|
41321f6f70 | ||
|
|
cc6c87903a | ||
|
|
e2b052a637 | ||
|
|
7e091e09dd | ||
|
|
a57d576805 | ||
|
|
90250adfb7 | ||
|
|
041e311f1e | ||
|
|
4b3fdde31b | ||
|
|
8db75f8b78 | ||
|
|
d00c3332e4 | ||
|
|
21bdd7c1f1 | ||
|
|
4e1a217670 | ||
|
|
b145f59312 | ||
|
|
8e3bb02e0e | ||
|
|
10223e3145 | ||
|
|
c6e9ad79d2 | ||
|
|
c49568101c | ||
|
|
c8d9faea29 | ||
|
|
a7180a8108 | ||
|
|
20972409e3 | ||
|
|
44877274da | ||
|
|
95aa8ece36 | ||
|
|
dd155912a7 | ||
|
|
f8401f3be3 | ||
|
|
5662023b10 | ||
|
|
a31cdb7765 | ||
|
|
b75d52ffc3 | ||
|
|
07d110a064 | ||
|
|
932e858994 | ||
|
|
1603eb8611 | ||
|
|
53f9ab9a00 | ||
|
|
69b26c1fa5 | ||
|
|
b104abeae6 | ||
|
|
cbe353fb93 | ||
|
|
f0ab804cc1 | ||
|
|
30df3ce0fe | ||
|
|
65121ca1ad | ||
|
|
732ba2b774 | ||
|
|
7f0099f1f6 | ||
|
|
ae264f71a3 | ||
|
|
399cd7ab19 | ||
|
|
e93b0c352a | ||
|
|
d28768cb30 | ||
|
|
c103ab4d32 | ||
|
|
573ac33182 | ||
|
|
b36cd3ac70 | ||
|
|
f43f5bcdc7 | ||
|
|
1e17f912ec | ||
|
|
1df8ef42cf | ||
|
|
49b218fa7b | ||
|
|
5ba1547f4f | ||
|
|
037c6cf67a | ||
|
|
2f5c911e2f |
@@ -5,3 +5,4 @@ __pycache__
|
||||
*.pyo
|
||||
tests/
|
||||
.env*
|
||||
/AGENTS.md
|
||||
|
||||
@@ -45,6 +45,9 @@ GID=1000
|
||||
# REQUIRED if you expose the container on anything other than 127.0.0.1.
|
||||
# Without a password, anyone who can reach the port can run commands as
|
||||
# the agent.
|
||||
#
|
||||
# All three compose files forward this into the WebUI container, so
|
||||
# uncommenting it here is sufficient — no compose edit needed:
|
||||
# HERMES_WEBUI_PASSWORD=change-me-to-something-strong
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -118,3 +121,15 @@ GID=1000
|
||||
# bind), ALL THREE containers must mount the SAME host path and run as the
|
||||
# SAME UID/GID. Mismatched UIDs → "Permission denied" → the WebUI crashes
|
||||
# on every HTTP request because it can't read its own auth signing key.
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Gateway API server — multi-container only
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# The two/three-container compose files forward this into the hermes-agent
|
||||
# container. The agent only starts the gateway API listener (port 8642) when
|
||||
# a usable API_SERVER_KEY is present (>=16 chars) — API_SERVER_ENABLED alone
|
||||
# does nothing. Without a key, the gateway daemon still runs (messaging +
|
||||
# cron) but the WebUI's Tasks/System pill shows "Gateway endpoint not
|
||||
# reachable". Set a long random string here to enable it; the same value is
|
||||
# forwarded to the WebUI as HERMES_WEBUI_GATEWAY_API_KEY automatically:
|
||||
# API_SERVER_KEY=replace-with-a-long-random-string
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Normalize line endings to LF in the repository.
|
||||
* text=auto
|
||||
|
||||
# Shell scripts must stay LF on checkout too. A CRLF shebang (`#!/bin/bash\r`)
|
||||
# breaks the Docker image: the kernel can't find interpreter `/bin/bash\r`, so
|
||||
# local `docker compose up -d --build` on Windows machines with
|
||||
# `core.autocrlf=true` fails with `exec /hermeswebui_init.bash: no such file
|
||||
# or directory`.
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -22,6 +23,7 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -34,7 +36,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
scenario: [normal, terminal-error]
|
||||
include:
|
||||
- name: normal
|
||||
script: tests/browser_conversation_lifecycle.py
|
||||
scenario: normal
|
||||
- name: terminal-error
|
||||
script: tests/browser_conversation_lifecycle.py
|
||||
scenario: terminal-error
|
||||
- name: historical-transcript-hydration
|
||||
script: tests/browser_historical_transcript_hydration.py
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -57,13 +67,22 @@ jobs:
|
||||
- name: Run conversation lifecycle gate
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
run: python ${{ matrix.script }}
|
||||
|
||||
- name: Run settlement frame proof
|
||||
if: matrix.scenario == 'normal'
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: normal
|
||||
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conversation-lifecycle-failure-${{ matrix.scenario }}
|
||||
name: conversation-lifecycle-failure-${{ matrix.name }}
|
||||
path: lifecycle-artifacts
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -87,6 +87,176 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Startup-health gate for UID/GID auto-detection (#7027).
|
||||
#
|
||||
# The compose variants above all mount a hermes-home volume, so they never
|
||||
# exercise the plain single-container shape from the issue: a state directory
|
||||
# bind-mounted from a host directory owned by a non-1024 user, /workspace left
|
||||
# as the image's own build-time 1024, and no explicit WANTED_UID/WANTED_GID.
|
||||
# Before the fix the auto-detector read /workspace, remapped to 1024, failed
|
||||
# its own state-dir writability check and restart-looped. Source-level
|
||||
# invariants can't catch that — only actually booting the container can.
|
||||
state-dir-uid:
|
||||
name: State-dir UID detection (#7027)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compose-config, build-image]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Restore Docker image from cache
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
tags: ghcr.io/nesquena/hermes-webui:latest
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Boot on a host-owned state mount with no explicit IDs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="hermes-smoke-uid-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
HOST_UID=1001
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-uidstate-XXXXXX)"
|
||||
sudo chown -R "$HOST_UID:$HOST_UID" "$STATE_DIR"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker logs "$CONTAINER" 2>&1 | tail -200 || true
|
||||
docker rm -f "$CONTAINER" || true
|
||||
sudo rm -rf "$STATE_DIR" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Deliberately NOT mounting /workspace: the point of this gate is that
|
||||
# the stock image's own /workspace (owned 1024:1024) must not win over
|
||||
# the host-owned state mount.
|
||||
echo "::group::docker run"
|
||||
docker run -d --name "$CONTAINER" \
|
||||
-p 127.0.0.1:8788:8787 \
|
||||
-e HERMES_WEBUI_STATE_DIR=/app/data \
|
||||
-v "$STATE_DIR":/app/data \
|
||||
ghcr.io/nesquena/hermes-webui:latest
|
||||
echo "::endgroup::"
|
||||
|
||||
# ----- The product gate: the container must reach /health -----
|
||||
echo "::group::Probe /health"
|
||||
attempts=0
|
||||
max_attempts=60
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8788/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 after $max_attempts attempts (~5m)"
|
||||
echo " Startup logs follow in the cleanup group below."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
|
||||
echo "❌ Container exited before /health came up — this is the #7027 restart loop"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "✅ /health = 200 after $attempts attempts"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Verify the detected identity"
|
||||
LOGS="$(docker logs "$CONTAINER" 2>&1)"
|
||||
|
||||
# The identity must have been detected from the state dir, not /workspace.
|
||||
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected UID: ${HOST_UID} \(from /app/data\)"; then
|
||||
echo "❌ UID was not auto-detected from the state directory (#7027)"
|
||||
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$LOGS" | grep -qE -- "-- Auto-detected GID: ${HOST_UID} \(from /app/data\)"; then
|
||||
echo "❌ GID was not auto-detected from the state directory (#7027)"
|
||||
echo "$LOGS" | grep -E -- "-- (Auto-detected|WANTED_)" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# And the runtime user must really carry it.
|
||||
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
|
||||
ACTUAL_GID="$(docker exec "$CONTAINER" id -g hermeswebui)"
|
||||
if [ "$ACTUAL_UID" != "$HOST_UID" ] || [ "$ACTUAL_GID" != "$HOST_UID" ]; then
|
||||
echo "❌ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, expected ${HOST_UID}:${HOST_UID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ hermeswebui runs as ${ACTUAL_UID}:${ACTUAL_GID}, detected from the state mount"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Startup log scan"
|
||||
BAD_PATTERNS='!! ERROR|!! Exiting script|Failed to verify state directory|Permission denied'
|
||||
if echo "$LOGS" | grep -E -i "$BAD_PATTERNS"; then
|
||||
echo "❌ Startup logs contain known-bad pattern (see above)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No known-bad patterns in startup logs"
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Explicit WANTED_UID=1024 must survive auto-detection
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="hermes-smoke-uid1024-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
STATE_DIR="$(mktemp -d -t hermes-smoke-uid1024-XXXXXX)"
|
||||
HERMES_DIR="$(mktemp -d -t hermes-smoke-uid1024home-XXXXXX)"
|
||||
# Both mounts are owned by a *different* UID than the one requested.
|
||||
# The hermes-home mount is what makes this discriminating: it is a
|
||||
# probe candidate the pre-fix script already read, so if the explicit
|
||||
# 1024 is still treated as "unset" the container remaps to 1001 and
|
||||
# the assertion below fails. World-writable state dir so the only
|
||||
# thing under test is which identity wins, not mount permissions.
|
||||
sudo chown -R 1001:1001 "$STATE_DIR" "$HERMES_DIR"
|
||||
sudo chmod 0777 "$STATE_DIR"
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo "::group::Cleanup (rc=$rc)"
|
||||
docker logs "$CONTAINER" 2>&1 | tail -100 || true
|
||||
docker rm -f "$CONTAINER" || true
|
||||
sudo rm -rf "$STATE_DIR" "$HERMES_DIR" || true
|
||||
echo "::endgroup::"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker run -d --name "$CONTAINER" \
|
||||
-p 127.0.0.1:8789:8787 \
|
||||
-e HERMES_WEBUI_STATE_DIR=/app/data \
|
||||
-e WANTED_UID=1024 \
|
||||
-e WANTED_GID=1024 \
|
||||
-v "$STATE_DIR":/app/data \
|
||||
-v "$HERMES_DIR":/home/hermeswebui/.hermes \
|
||||
ghcr.io/nesquena/hermes-webui:latest
|
||||
|
||||
attempts=0
|
||||
max_attempts=60
|
||||
until curl --fail --silent --max-time 5 http://127.0.0.1:8789/health > /dev/null; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge "$max_attempts" ]; then
|
||||
echo "❌ WebUI /health never returned 200 with explicit WANTED_UID=1024"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
|
||||
echo "❌ Container exited before /health came up"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
ACTUAL_UID="$(docker exec "$CONTAINER" id -u hermeswebui)"
|
||||
if [ "$ACTUAL_UID" != "1024" ]; then
|
||||
echo "❌ explicit WANTED_UID=1024 was overwritten by auto-detection — got ${ACTUAL_UID} (#7027)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ explicit WANTED_UID=1024 preserved (sentinel no longer shadows the valid value)"
|
||||
|
||||
smoke:
|
||||
name: Smoke ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
# it so test coverage exists. If mcp install fails (Python 3.13
|
||||
# wheel not yet available, etc.), tests/test_mcp_server.py uses
|
||||
# importorskip and the matrix stays green.
|
||||
pip install mcp || echo "mcp install failed — test_mcp_server.py will importorskip"
|
||||
pip install "mcp<2" || echo "mcp install failed — test_mcp_server.py will importorskip"
|
||||
|
||||
- name: Get Playwright version
|
||||
if: needs.changes.outputs.docs_only != 'true'
|
||||
|
||||
@@ -77,3 +77,10 @@ graphify-out/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
docs/ui-ux/review-shots/
|
||||
|
||||
# Python build metadata (never commit generated egg-info)
|
||||
*.egg-info/
|
||||
|
||||
# Local WebUI runtime data and credentials
|
||||
.webui_password
|
||||
attachments/
|
||||
|
||||
@@ -59,6 +59,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi
|
||||
auth.py Optional password authentication, signed cookies, passkeys/WebAuthn
|
||||
config.py Discovery, globals, model detection, reloadable config
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers
|
||||
goals.py Persistent-goal commands and profile-scoped native GoalManager bridge
|
||||
models.py Session model + CRUD, per-session profile tracking, CLI/state.db bridge
|
||||
profiles.py Profile state management, hermes_cli wrapper
|
||||
onboarding.py First-run onboarding status, real provider config writes, OAuth linking, readiness detection
|
||||
@@ -235,6 +236,24 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
|
||||
title_from(): takes messages list, finds first user message, returns first 64 chars.
|
||||
Called after run_conversation() completes to set the session title retroactively.
|
||||
|
||||
#### Imported `state.db` sidebar projection
|
||||
|
||||
`api.models.get_cli_sessions()` projects conversations from the active Hermes
|
||||
profile's `state.db` into sidebar-shaped rows. The default projection keeps
|
||||
interactive sources (CLI, TUI, ACP, messaging, and similar user-facing sessions)
|
||||
in a bounded 20-row candidate window. Background sources use independent recovery
|
||||
passes so a high-volume worker source cannot consume that interactive window:
|
||||
|
||||
- Cron: up to `CRON_PROJECT_CHIP_LIMIT` rows.
|
||||
- Webhook: up to `WEBHOOK_PROJECT_CHIP_LIMIT` rows.
|
||||
- Kanban: up to `KANBAN_PROJECT_CHIP_LIMIT` rows.
|
||||
|
||||
Source-specific views still use their dedicated bounds, and the later sidebar
|
||||
visibility stage decides whether recovered background rows are shown. In
|
||||
`all_profiles=True` mode the per-profile source bounds are disabled before rows
|
||||
are merged; cross-profile scoping, visibility, deduplication, and final route
|
||||
limits remain downstream responsibilities.
|
||||
|
||||
### 4.3 SSE Streaming Engine
|
||||
|
||||
This is the most architecturally interesting part. Two endpoints cooperate:
|
||||
@@ -376,6 +395,29 @@ read_file_content(workspace, rel):
|
||||
- Reads as UTF-8 with errors='replace' (binary files show replacement chars)
|
||||
- Returns {path, content, size, lines}
|
||||
|
||||
### 4.8 Persistent Goal Profile Boundary
|
||||
|
||||
`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook.
|
||||
Hermes Agent's native `GoalManager` is the authoritative owner of goal evaluation,
|
||||
continuation decisions, wait barriers, failure counters, contracts, subgoals, and
|
||||
`state.db` persistence.
|
||||
|
||||
For a profile-scoped WebUI session, the bridge delegates only when the Agent exposes
|
||||
both the context-local `set_hermes_home_override()` API and call-time default
|
||||
`SessionDB` path resolution. The bridge probes the resolved default path under the
|
||||
selected context before constructing the native manager, then binds that profile's
|
||||
Hermes home before every native call. The override is reset in a `finally` block after
|
||||
every operation, so concurrent sessions using the same session ID under different
|
||||
profiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the
|
||||
same scoped native persistence path.
|
||||
|
||||
Older Hermes Agent versions that lack either capability continue through
|
||||
`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit
|
||||
`state.db` path. This includes intermediate versions whose context API is present but
|
||||
whose default `SessionDB()` path remains frozen at module import. Keep this fallback
|
||||
compatibility-only: new goal semantics belong in Hermes Agent's native manager rather
|
||||
than a second WebUI implementation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Architecture: Current State
|
||||
|
||||
+456
-72
@@ -5,24 +5,320 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **The repository is now pip-installable with standard packaging metadata.** `pyproject.toml` gains a `[build-system]` + `[project]` section (setuptools + setuptools-scm) so packagers and distros can `pip install .` and get a proper wheel (`api` + `static` bundled). The normal `bootstrap.py` / `start.sh` / `ctl.sh` source-checkout launch path is unchanged. setuptools-scm writes its version to a separate `api/_scm_version.py` (not the Docker-owned `api/_version.py`), and the runtime version detector reads it as an explicit fallback (normalizing the PEP 440 value to a `v…` form) so an installed wheel reports a real version instead of `unknown` — Docker and git-checkout version resolution are byte-identical to before. Thanks @rodboev. (#6337, #2695)
|
||||
|
||||
- **Transparent Stream can now hide its per-event timestamp chips.** A new opt-in setting (Settings → the Transparent Stream activity area) lets you suppress the small per-event timestamp chips inside Transparent Stream while keeping the assistant response footer time visible — narrowing the visual noise without removing timing information entirely. Default is unchanged (chips shown). Thanks @rodboev. (#6130, #6099)
|
||||
|
||||
- **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258)
|
||||
|
||||
- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155)
|
||||
|
||||
- **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144)
|
||||
|
||||
- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145)
|
||||
|
||||
### Added
|
||||
|
||||
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
|
||||
- **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Structured (multimodal) message content stored in `state.db` renders as its real content instead of a placeholder, without dropping or misattributing turns.** When the session projection decodes the `state.db` content sentinel, list-valued content now gets an out-of-band identity — a `("structured_content", canonical)` tuple that no scalar string can compare equal to — shared across the merge, dedup, content, and visible reconciliation keys, so an in-band string can't collide with a rich row and an image-only row can no longer vanish from the projection. The expensive canonical serialisation is memoised per content object for the duration of a single `merge_session_messages_append_only()` call (scoped through a `ContextVar`, keyed by object identity with the object held alive so an id can't be reused for a different list mid-call), restoring one serialisation per list per call. Empty/falsy content keys exactly as before. Thanks @totalitarian. (#7492)
|
||||
|
||||
- **An empty composer no longer inflates to fit its own placeholder on the JavaScript sizing path.** The fallback resizer measured `scrollHeight` for an empty textarea, which reflects the *placeholder* — so a long busy or compression hint (which wraps to two or three lines) grew the empty composer to roughly 71px, and only after a resize that happened while empty, making the resting height depend on history rather than content. The inline height is now cleared when the value is empty so CSS `min-height` defines the resting size, matching the native path. (#6760)
|
||||
|
||||
- **Delegated subagent sessions nest under the conversation that spawned them instead of landing at the top of the sidebar.** The sidebar forces a *cross-surface* child row to top level when its parent row belongs to another surface (a Telegram/messaging parent), so an independent continuation doesn't get stacked under a foreign thread. A delegated subagent is also technically cross-source relative to its WebUI parent, so it was swept up by that rule and surfaced as a bare top-level row with no indication of what spawned it. Delegated subagents are now exempt from that branch and attach to their visible parent, while independent cross-surface continuations still stay top level. The delegated role is resolved in strict precedence order from the first non-blank of `raw_source` → `source_tag` → `source` and additionally requires the backend's child-session flag, so a stale lower-priority field can't promote an independent row into a delegated one. Thanks @lowlandsheperd. (#7263)
|
||||
|
||||
- **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131)
|
||||
|
||||
- **Signal gateway conversations are classified as messaging sessions instead of falling into the default/other bucket.** The messaging-source allowlists never learned about Signal, so a session started on the Signal gateway was normalized to `other`, failed the client-side external-session check the session list uses to render and refresh gateway sessions, and never showed up in the sidebar. `signal` (labeled "Signal") is now in the messaging-source sets on both sides — the Python normalizer (`MESSAGING_SOURCES` / `SOURCE_LABELS`), which also feeds the server-side messaging allowlist, and the client-side classifier (`_MESSAGING_RAW_SOURCES` / `_MESSAGING_SOURCE_LABELS`) — so Signal sessions group, label, and list exactly like Telegram, Discord, Slack, WeCom, and Matrix. No other source's classification changes. Thanks @webtecnica. (#7571)
|
||||
|
||||
- **Opening a session no longer stalls just because Codex refreshed its model cache in the background.** The WebUI keys its 24-hour `/api/models` cache partly on Codex's `~/.codex/models_cache.json`, but that file was fingerprinted by `mtime` + size — and Codex rewrites it on its own refresh timer, bumping those stat fields even when the model catalog is byte-identical (only a volatile `fetched_at` timestamp changed). Every Codex refresh therefore invalidated the cache, and the next session open paid a full live rebuild whose serial provider probes stalled the open (#7540). The Codex cache is now fingerprinted by its *content* with the refresh-timestamp fields (`fetched_at`, `updated_at`) stripped, so a timestamp-only rewrite keeps the cache while any genuine catalog change (models, `etag`, `client_version`) still invalidates it. A malformed or pathologically deep cache file degrades safely to the old stat fingerprint rather than erroring. Thanks @webtecnica. (#7556, closes #7540)
|
||||
|
||||
- **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510)
|
||||
|
||||
- **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543)
|
||||
|
||||
- **Custom providers that Hermes auto-discovered now show their full live `/v1/models` catalog again instead of just the saved models.** When a provider entry carries `models_discovered: true` alongside a `models:` mapping of per-model metadata (vision, context length), that mapping is discovery metadata — not a hand-curated allowlist — but the WebUI treated it as one and suppressed the live catalog, collapsing the model picker to only the already-saved IDs. Such a provider now defers to its live `/v1/models` catalog (matching the Hermes Agent's own `_models_config_is_allowlist` semantics), while an explicit `discover_models: false` opt-out (including the Agent-compatible string forms `false`/`no`/`0`) still pins the configured list, and a transient empty live probe falls back to the configured models rather than dropping the provider from the picker. Thanks @evgenyponomarev. (#7409, closes #7404)
|
||||
|
||||
- **Ctrl-C and `ctl.sh`-launched daemons now shut down gracefully instead of leaving the server running.** The WebUI installed a SIGTERM handler that drains in-flight work through `serve_forever()`'s `finally`, but SIGINT fell through to Python's default, so a daemon started via `./ctl.sh start` could only be stopped with `kill <pid>` and the Settings "Stop server" button did not stop it. SIGINT now shares the same idempotent graceful-shutdown handler as SIGTERM. Thanks @aniruddhaadak80. (#7315, closes #7078)
|
||||
|
||||
- **Static assets are served with correct MIME types instead of a `text/plain` catch-all.** `_serve_static()` defaulted every extension outside its small built-in allowlist to `text/plain; charset=utf-8`, mislabeling PDFs, APKs, WASM and other binaries. Unknown extensions now resolve through Python's standard `mimetypes` database, with an explicit deterministic entry for `.apk` (whose type varies across host MIME databases), and fail closed to `application/octet-stream` for unknown or pre-encoded suffixes (`.svgz`/`.tgz`/`.js.gz`) so still-compressed bytes are never advertised with their decoded media type. Path containment, gzip/ETag/cache handling, and the text charset path are unchanged. Thanks @Oidaladn0. (#7372)
|
||||
|
||||
- **Chat-attachment uploads are created atomically so a raced duplicate or symlink can't silently overwrite or redirect the write.** `handle_upload` deduped filenames with an `exists()` check and then wrote with a plain `write_bytes`, so two concurrent uploads of the same name both passed the check and the last writer silently won — leaving one client a `200` naming bytes no longer on disk. The final create now uses the existing descriptor-anchored `open_anchored_create_fd` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mirroring the #3398 workspace-upload hardening): a raced duplicate returns `409` instead of overwriting, and a symlink raced into the attachment path cannot redirect the write outside the session inbox. The normal success path, size/MIME handling, and `-1` dedup suffixing are unchanged. Thanks @zicochaos. (#7337)
|
||||
|
||||
- **Cron jobs can be delivered to your messaging platforms again.** `GET /api/crons/delivery-options` had been returning only `local` and `origin`, so Telegram, Discord, Slack, Feishu and every other platform was silently missing from the cron delivery picker — a scheduled job could not be pointed anywhere. The Agent moved its delivery-platform allowlist to a new module, and the endpoint still imported the old path inside a bare `except` that fell back to an empty set, so the import error was swallowed and the feature degraded with nothing logged. The allowlist is now resolved across both module paths so the picker survives future relocations, with a regression test that fails loudly instead of emptying out. (#7525)
|
||||
|
||||
- **Auxiliary-model settings no longer silently discard a configured provider that is missing from the model catalog.** The provider dropdown was built only from providers the catalog returned, so when a task's configured provider was absent (for example its group exposes no models), nothing matched, the select fell back to its first entry (`auto`), and the next Apply persisted `auto` — quietly throwing away the user's choice on a row they never touched. The configured provider is now kept selectable so it round-trips through Apply unchanged. Thanks @webtecnica. (#7519, closes #7486)
|
||||
|
||||
- **A custom provider whose model endpoint is unreachable no longer disappears from the auxiliary model picker.** A named provider whose `/v1/models` probe fails still reaches the client as a group with no models plus an endpoint-error marker, and the main model picker renders it with an unreachable-endpoint hint — but the auxiliary picker filtered out every group without models, so the provider vanished from those selects entirely. The auxiliary picker now keeps it and shows the same hint instead of an empty dropdown. Thanks @webtecnica. (#7522, closes #7521)
|
||||
|
||||
- **The OpenRouter setup wizard now offers Z.AI models under OpenRouter's own namespace instead of IDs that 404.** `_FALLBACK_MODELS` is authored for the *direct* provider endpoints, so its nine Z.AI entries carry the provider-native `zai/` prefix — and the OpenRouter onboarding setup reused that list verbatim. OpenRouter serves the same models as `z-ai/…`, so anyone who picked a Z.AI model during OpenRouter onboarding was handed an ID the provider does not recognise and their first message failed. The IDs are now translated at the OpenRouter projection boundary only; the fallback catalog and the direct Z.AI setup keep their own namespace. Thanks @webtecnica. (#7518, closes #7514)
|
||||
|
||||
- **Recovered "thinking" cards no longer multiply on every turn after context compaction.** When a compacted history left the display transcript longer than the model-facing result, a recovered reasoning-only row could be re-inserted *after* the new reply each turn — doubling the recovered block geometrically (1, 2, 4, 8…). Settlement now fixes the active-turn boundary before restoring reasoning metadata and only restores a historical reasoning row directly in front of its own API-safe successor (requiring a unique stable-id or exact-identity match), failing closed to the current-turn boundary when turn ownership can't be proven. The synchronous chat path mirrors the streaming path, so live and reloaded transcripts stay consistent. Thanks @carlotestor. (#7443, refs #7388)
|
||||
|
||||
- **Command-backed provider credentials no longer break the agent cache or discard conversation state between turns.** When a provider used `key_cmd`, the runtime supplied a lazy callable token source, but the cache signature tried to call `.encode()` on it; every turn therefore missed the cache and rebuilt the agent. Callable credentials now use a stable, secret-free cache marker while the existing runtime refresh path still receives the callable itself, so token resolution stays lazy and agent context is reused. Thanks @ArushKhasru. (#7398, closes #7396)
|
||||
|
||||
- **Agent turns now run in the workspace you selected instead of the server's install directory.** Because the WebUI runs the agent in-process, anything that resolved a default working directory from the process (`os.getcwd()`) landed in the Hermes install tree rather than the chosen workspace — so conductor children could record the wrong working directory and even pick up the install tree's own `AGENTS.md` as workspace doctrine. Each turn now binds its workspace to a task-local runtime cwd, so concurrent turns keep their own workspace and the binding is restored cleanly when the turn ends (a malformed workspace path degrades gracefully instead of failing the turn). Thanks @samfoy. (#7351)
|
||||
|
||||
- **A stale WebUI runtime after an Agent update is now reported and rejected without an unsafe automatic restart, and its update-marker read can no longer hang or exhaust memory.** When the running Agent revision no longer matches the on-disk checkout, WebUI keeps returning the manual-restart `409` and now surfaces richer update diagnostics while leaving the actual restart to the operator (it never restarts itself into a possibly half-applied checkout). The shared update marker — attacker-adjacent state any process with write access to the Agent home can create — is read defensively: the read never follows a symlink, never blocks on a FIFO/device, and never reads an unbounded file, classifying anything that is not a small regular file as unknown (fail-closed), with a portable fallback on platforms lacking the atomic open flags. Thanks @hrdwdmrbl. (#7160)
|
||||
|
||||
- **Sidebar session polling no longer retains full transcripts and run-journal detail in the response cache.** Cached rows are now projected to the canonical sidebar field allowlist before storage, heavy metadata is stripped across index and full-scan paths, nested request values are isolated, and invalidated rebuilds are rejected atomically instead of being reinserted after a concurrent clear. Thanks @martindell. (#7489)
|
||||
|
||||
- **The workspace switcher now lists workspaces in your configured order instead of re-sorting them alphabetically.** The chat-header workspace dropdown alphabetized its entries client-side, so it disagreed with the drag-and-drop order shown in the Workspaces settings panel (and never kept the default "Home" workspace first). The dropdown now renders the server's stored order verbatim — the same order as the settings view — with no client-side re-sort. Thanks @CharlesMcquade. (#7317)
|
||||
|
||||
- **Auxiliary-model settings now persist provider-native model IDs instead of silently dropping the provider prefix.** When an auxiliary model (title/summary/etc.) was chosen as a provider-qualified ID, settings canonicalization stripped the `@provider:` prefix and could persist a bare name that resolved under the wrong provider group. The picker now preserves the provider-native ID, dedupes qualified/unqualified variants, and fails closed on an ambiguous mismatch rather than persisting a wrong upstream model. Thanks @lowlandsheperd. (#7261)
|
||||
|
||||
- **Hardened a streaming race that could read a stream queue mid-teardown.** Several code paths read the in-memory SSE stream registry with a bare, unlocked `STREAMS.get()`, so a read that raced a concurrent teardown (cancel/finish popping the queue under `STREAMS_LOCK`) could observe-and-use a queue the registry had already released. Registry reads now go through a lock-disciplined `peek_stream()` helper that takes `STREAMS_LOCK`, and a self-enforcing test forbids reintroducing a bare read. Callers keep their existing empty/None-guard fallbacks, so behavior on the common non-race path is unchanged. Thanks @zicochaos. (#7339)
|
||||
|
||||
- **Automatic session titles no longer keep an internal placeholder for workspace-prefixed multimodal first turns, and internal scaffolding no longer leaks into generated titles.** Title eligibility compared persisted content that still carried WebUI workspace/attachment scaffolding against a sanitized visible title, so a workspace-prefixed structured multimodal first turn could never be recognized as provisional and stayed stuck on its placeholder title; the same internal scaffolding could also reach the title provider's input. Title generation now sanitizes title-facing structured text (stripping versioned workspace sentinels and generated attachment suffixes) across eligibility, first-exchange generation, manual prefer-latest generation, and adaptive refresh, while preserving genuine user content and native image parts. Persisted and model-facing message content is unchanged (titles only read it), and a real user-chosen title is never misclassified as provisional. Thanks @starship-s. (#7306)
|
||||
|
||||
- **The composer footer no longer jitters the transcript while a reply streams.** During SSE streaming, the composer footer's fit-stage probe transiently applied and reverted layout classes, which nudged the transcript viewport a few pixels backward on each frame (a visible clamp/jump on the crown-jewel chat surface). The fit probe now runs synchronously with the transcript geometry frozen and the caller's exact styles restored in a `finally`, so footer sizing is measured without moving the reader's scroll position; adaptive stage selection is unchanged. Thanks @ruizanthony. (#7275)
|
||||
|
||||
- **A Windows self-restart now relaunches the actual server instead of replaying whatever process launched it.** The Windows restart path replayed the launching process's `sys.argv`, so a server started indirectly (e.g. under pytest) would restart *that* process rather than the server. It now resolves the canonical replacement command per packaging mode — a source or pip install relaunches `server.py` directly (preferring windowless `pythonw.exe` when present), while frozen/packaged argv is left unchanged — and preserves host, port, state, workspace, and agent environment across the restart. The POSIX (exec) restart path is unchanged. Thanks @rodboev. (#7270, closes #7195)
|
||||
|
||||
- **A malformed or non-object JSON request body now returns a clear `400 Invalid JSON body` instead of a misleading "missing field" error.** `read_body()` used to silently turn any JSON parse failure into `{}`, so a client that sent broken JSON got a confusing `Missing required field` 400, and a non-object body (a JSON array or scalar) passed straight through. It now rejects malformed JSON and non-object bodies with a clean 400 at the PATCH/DELETE/PUT/POST/TTS entry points (oversized bodies still return 413). Empty and whitespace-only bodies are still treated as `{}`, so body-optional endpoints (e.g. `DELETE /api/mcp/servers/{name}`) are unaffected. Thanks @zicochaos. (#7336)
|
||||
|
||||
- **A pre-`source`-column `state.db` no longer floods `errors.log` with the same warning every ~15 seconds.** When an older profile's `state.db` lacks the `source` column, the session-listing path logs a "no 'source' column" warning and returns an empty list — but the sidebar poll (behind a 5s cache) re-ran it every ~15s, so a single obsolete-schema profile DB produced a duplicate warning line for the life of the process (an operator saw 276 identical lines in ~70 min). The warning is now emitted once per resolved `state.db` path per process lifetime; the degraded-path behavior is otherwise unchanged, and a restart warns again. Thanks @rodrigogs. (#7476, closes #634)
|
||||
|
||||
- **Automatic session-title generation now publishes the conversation context, so relay backends route the title call to the right target.** The aux-client title generator (`generate_title_raw_via_aux` → `call_llm`) ran with no Agent conversation context published, so relay targets that derive routing from it (e.g. OpenCode Go) mis-routed the title request (#7470). The title call now republishes the WebUI session id as the Agent's ambient conversation context for the duration of the aux call and resets it on every exit path (success, early return, and exception), using the real runtime's `ContextVar` so concurrent title calls stay isolated. An older or absent Agent runtime without the context setter keeps the previous behavior rather than failing title generation. Thanks @webtecnica. (#7474, closes #7470)
|
||||
|
||||
- **Live progress text in Transparent Stream no longer tears in the middle of a word.** During a streaming turn, a live progress row could split a word across a block boundary and render the tail on its own line (e.g. `Ces deu` / `x fichiers passent.`). The streamed text was always intact — it was purely a client-side render artifact in the live fade/reconcile path. The renderer now adopts the parser-owned DOM once after a parser-owner replacement (keeping the per-token fast path for normal ticks) and handles the source-space cursor at block boundaries, so live prose stays whole with no lost, duplicated, or reordered text. Verified with the crown-jewel stream gate (no flicker, no alternating, no prose reversals across transparent/worklog/hidden modes). Thanks @ruizanthony. (#7082)
|
||||
|
||||
- **A slash-style model ID picked under a session provider that differs from the profile default now routes to the right provider instead of 404ing.** When you selected a model whose ID contains a `/` (e.g. a Nous portal row `upstage/solar-pro4:free`) while the session's provider differed from the profile default, `model_with_provider_context()` dropped the provider hint, so the request went to the *default* provider's base URL (e.g. `api.x.ai` under an xAI-OAuth default) and 404'd. The resolver now emits an explicit `@provider:model` hint when the session provider is a known static/portal provider or a `custom:<slug>` that resolves to a unique `custom_providers[]` entry, while still keeping the bare ID for unknown/ambiguous slugs so existing custom/proxy base-URL routing stays in charge. Ambiguous custom slugs fail closed to a persisted error rather than crashing the worker. Thanks @Manny7717. (#7356, closes #7333)
|
||||
|
||||
- **Editing and resubmitting a message can no longer truncate most of a long session's history if you click "Send edit" more than once.** `submitEdit` was re-entrant: its only guard was `S.busy`, which the send path doesn't set until after two multi-second awaits (`_ensureAllMessagesLoaded` on a long session, then the truncate round-trip). On a laggy instance that left a 10s+ window in which a second click started another full truncate — and because the first call sets `_oldestIdx = 0`, the second call recomputed `keep_count` from a still-window-relative index, producing a far smaller keep count that could delete most of a 2000-message session. A `_submitEditInFlight` guard is now acquired synchronously before the awaits and released in a `finally` (so an early return or throw can't wedge editing off), while the absolute keep count is still captured up front so a single legitimate submit is unaffected. The Enter-to-submit path clicks the same button, so it's covered too. Thanks @alanjds. (#7277)
|
||||
|
||||
- **Read-only child sessions stay grouped after their writable siblings in the sidebar lineage view.** When a session lineage is collapsed, read-only child sessions (e.g. shared/observed sessions) could interleave with writable children instead of sorting after them, making the group order unstable. The comparator now orders writable children first and read-only children after, within each lineage, with a stable total order that tolerates missing order keys and both the `read_only` and `is_read_only` shapes. Session open/selection is unaffected (still keyed by `session_id`, independent of row position). Thanks @igorroncevic. (#5888)
|
||||
|
||||
- **Inline event-handler arguments across the UI are now escaped for the JavaScript context, closing an attribute-injection class.** `esc()` only escapes for HTML, but the browser decodes attribute entities *before* it parses an inline handler's JavaScript, so a value interpolated as `onclick="fn('${esc(id)}')"` let a quote in the data break out of the JS string literal (the #3797 bug class). A shared `jsArg()` helper (`esc(JSON.stringify(String(v)))` — `JSON.stringify` supplies its own quotes, `esc()` then makes the result attribute-safe) now wraps **every** inline-handler string argument across cron/kanban/checkpoint/gateway/passkey/notes actions, session handoff hints, and onboarding device-code copy. The one-off `_kanbanJsArg` that previously fixed this for kanban buttons alone is removed in a clean cutover. Thanks @zicochaos. (#7335, closes #3797)
|
||||
|
||||
- **Nested lists that mix bullets and numbers render as a real hierarchy, and a very deeply nested list no longer blanks the conversation.** The renderer used to draw one marker family and then re-parse its own generated HTML with the other, so a list mixing `-` and `1.` lost its nesting. It now builds the nested `<ul>`/`<ol>` tree in a single pass. That tree is serialized with an explicit work stack rather than recursion — the first version of this fix recursed once per nesting level and threw a `RangeError` on a pathologically deep list, and because rendering happens after the transcript is cleared the uncaught error blanked the whole session. Output is byte-identical to the previous renderer across 50,000 randomized trees. Thanks @webtecnica. (#6716, closes #6700)
|
||||
|
||||
- **When a skill isn't found, the error reply no longer implies the skill list is complete when it was truncated.** `/api/skills/content` capped the `available_skills` courtesy list at 20 names but presented it as the whole set, so a caller (agent or human) that couldn't find its skill there would wrongly conclude it isn't installed. The reply now includes `total_skills` and `available_skills_truncated`, and the hint reads "Showing 20 of N skills…" when the list is cut, so a missing skill is never mistaken for an absent one. Thanks @elight. (#7428, closes #7426)
|
||||
|
||||
- **Session title generation and the handoff summary work again on the Anthropic and Codex out-of-band paths.** A Hermes Agent refactor unified provider dispatch and removed the two response normalizers these paths called (`agent._normalize_codex_response` and `agent.anthropic_adapter.normalize_anthropic_response`), so on a current Agent both paths raised as soon as they ran — background title generation and the handoff summary silently produced nothing on those providers. Both call sites now go through the Agent's current transport API (`_get_transport(...).normalize_response(...)`), matching the Agent's own canonical helper including the Anthropic OAuth tool-prefix strip. Empty-response and incomplete-summary handling are unchanged. Thanks @zicochaos. (#7402)
|
||||
|
||||
- **A long final assistant message no longer stalls every other active stream for seconds.** The echo-suffix folding that removes a duplicated tail from streamed text re-normalized the entire remaining window for *every* candidate cut position — quadratic in the window size, so a ~6,000-character conclusion burned seconds of CPU while holding the GIL, freezing every other stream in the process. The window is now folded once and the cut point located by walking backwards across the echo, which is linear and produces byte-identical output on every input (verified by ~380,000 differential comparisons across Unicode whitespace, window sizes, straddling and repeated echoes, with zero divergences). Thanks @ruizanthony. (#7288)
|
||||
|
||||
- **Extensions that ship a `.gitkeep` (or `.gitignore`/`.gitattributes`/`.env.example`) install again instead of failing with "Unsafe archive member".** The archive validator rejected *any* dot-prefixed path segment, which also blocked the benign placeholder files extensions legitimately carry — so an extension with an empty `assets/` directory couldn't be installed at all. Archive install/uninstall now use a dedicated validator that permits a short allowlist of hidden files **as the final path segment only**; dot-directories (`.git/`, `.ssh/`) and every other dotfile (`.env`, secrets) stay rejected, as do traversal, absolute paths, encoded separators, NUL/backslash, and an allowlisted name used as a directory. The shared strict validator is unchanged and still gates static serving, asset URLs, and manifest paths, so an installed hidden file lands on disk but can never be fetched over HTTP or injected into a page (`.env.example` is installable but not servable; `.env` remains rejected outright). Thanks @asorourx. (#6620, closes #6619)
|
||||
|
||||
- **Passkey sign-in works behind a reverse proxy that rewrites `Host`.** WebAuthn scopes a credential to an "RPID" that must match the origin the browser is actually on, but the RPID was derived from the `Host` header — which a proxy fronting a separate SPA rewrites to its own upstream address, so registration and login failed with an RP ID mismatch. The RPID now comes from the request `Origin` (accepted only as a well-formed `http(s)` origin with a hostname, rebuilt from its parsed parts, otherwise falling back to `Host`). This is not a trust decision: the authenticator still only releases a credential whose RPID is a registrable suffix of the real page origin, `clientDataJSON.origin` must equal the origin stored with the challenge, the authenticator's `rpIdHash` must match that stored RPID, and the assertion signature is still verified against the stored credential public key. Thanks @cabluvsmkm2009-source. (#6772)
|
||||
|
||||
- **Trusted-header auth can be told to accept pipe-separated groups from the identity proxy.** An Authentik outpost (depending on its proxy provider / property mapping) can emit a group header like `admins|developpeur`; the parser only split on commas and newlines, so the whole string was read as one group name, matched nothing in the configured group→profile map, and the session silently fell back to the unbound `default` profile despite a legitimate mapped membership. Setting `HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR=1` now also treats `|` as a separator (comma and newline stay the default). It is opt-in on purpose: a group *name* can legitimately contain a literal `|`, so splitting on it unconditionally could silently change an existing deployment's profile binding. Repeated or adjacent separators collapse instead of producing empty group names. Thanks @SamsGuamejy. (#7331)
|
||||
|
||||
- **Jumping to the start of a long conversation while it is still streaming no longer opens a blank transcript, and an idle long session no longer re-renders itself several times a second.** With transcript virtualization on, the scroll listener rescheduled a virtualized render on every scroll event — including its own programmatic scrolls — so a settled long session sat in a ~5-6 renders/second loop that pinned a CPU core and broke text selection. The listener now skips rescheduling while a programmatic scroll is in flight, and the render window is restored after a settled render. "Jump to session start" during an active stream deliberately skips the full re-render (it would drop live Activity cards), so it now mounts the render window explicitly — without that, the jump landed at the top of an all-spacer transcript with no messages rendered. Thanks @LeonardoLGDS. (#7427, closes #6799)
|
||||
|
||||
- **The last character before a tool call is no longer dropped from streaming assistant text.** When a model emitted a `<function_calls>` block immediately after prose, the strippers that remove the XML block also trimmed the text, silently eating the final character of the sentence before it. Only leading whitespace is trimmed now, so the prose survives intact. Thanks @bsgdigital. (#7207)
|
||||
|
||||
- **A brand-new chat no longer shows a phantom "Compressing context" divider on its very first message.** The server appends a placeholder lifecycle row whenever a stream has events but nothing visible to project yet, and the worklog classified any such row reporting a bare `running` phase as the start of a context compression — so a session that never compressed anything grew a permanent, reload-surviving compression divider. Classification now requires an explicit compressing phase or matching text. Thanks @MuhammadUsamaMX. (#7447)
|
||||
|
||||
- **Searching the model picker matches the model names you actually see, and a punctuation-only search no longer returns every model.** OpenRouter curated entries were listed by raw id, so typing the display name (e.g. "Ox Alpha") found nothing; picker rows now carry the friendly name from the local OpenRouter metadata cache (read from disk only — never a network call — falling back to the raw id when unknown). Search also folds spaces, dots, hyphens and underscores, so "ox alpha", "ox-alpha" and "ox.alpha" all match — while a search consisting only of separators correctly matches nothing instead of everything. Thanks @webtecnica. (#7385, closes #7228)
|
||||
|
||||
- **The Insights period selector shows its dropdown chevron again.** An inline `background:` shorthand on the control reset the shared `select` background image, hiding the chevron and removing the padding reserved for it, so the filter looked like static text. The inline style moved into a class so the shared select styling stays authoritative, and the control keeps its compact width. Thanks @tomatotomata. (#6768, closes #6725)
|
||||
|
||||
- **Docker: secrets ending in `=` (like a base64 shared secret or password) are no longer truncated when the container reloads its environment after dropping privileges, so shared-secret auth and `HERMES_WEBUI_PASSWORD` matching keep working.** `docker_init.bash`'s `load_env()` reloaded the saved env snapshot with `while IFS='=' read -r key value`, and Bash silently drops a single trailing `=` from the last field — so any value ending in one `=` (e.g. `openssl rand -base64 32` output, which is 44 chars ending in `=`) came back one character short. The reload now reads each line whole and splits at the first `=` only, preserving embedded and trailing `=`; blank/comment lines are skipped and lines without `=` keep their prior empty-value behavior. Thanks @webtecnica. (#7433, closes #7432)
|
||||
|
||||
- **WebUI session titles are written to `state.db` again on the latest Hermes Agent, so `hermes sessions list` isn't blank for WebUI sessions.** The agent's `SessionDB.set_auto_title_if_empty` was renamed to `set_auto_title(session_id, title, *, source)` in a state-module refactor (released in agent v2026.9.7); the WebUI's background title sync still called the old name, whose `AttributeError` was silently swallowed — so auto-generated titles stopped persisting. The sync now calls `set_auto_title` with LLM provenance, preserving the exact prior behavior (only fills a NULL/lower-authority title, never clobbers a manual rename, de-duplicates colliding titles with a `#N` lineage suffix). Also repoints the gateway-approval test suite's `tools.approval._ApprovalEntry` reference to its post-split location (`tools.approval_gateway_wait`) via a test-only compatibility shim, so the agent-coupled tests run green across both the pre- and post-split agent.
|
||||
|
||||
- **Regenerating a response no longer refetches the entire conversation, so regenerate on a long chat is fast instead of stalling (and silently cancelling).** Clicking "regenerate" used to pull the whole transcript back from the server before it could rebuild the request, which on a large session caused a long UI freeze and could time out and silently drop the regeneration. The regeneration authority now reads only a bounded, sidecar-anchored tail of recent turns instead of the full transcript — and it is exact by construction: it takes the tail's prefix proof, keys, and rows from a single WAL-consistent database snapshot (a `data_version` check forces a full read if the database is written mid-snapshot), reuses the canonical projection and durable ordering so the bounded rows match the full reader byte-for-byte, and falls back to a full read whenever the skipped prefix isn't provably identical or the tail contains a duplicated turn key. If any of those invariants can't be met it reads the whole transcript exactly as before, so a concurrent edit or an unusual session can never produce a stale or reordered regeneration. Thanks @webtecnica. (#7204, closes #6826)
|
||||
|
||||
- **Loading the session sidebar is faster on installs with many delegated-subagent sessions — the subagent classification no longer runs a separate database probe per row.** Building `/api/sessions` used to open a `state.db` connection for every session that might be a delegated child, an N+1 pattern that grew with session count. The classification now reads the authoritative `state.db` source for all candidate rows through the existing batched overlay (one read-only connection, chunked 500-ID `IN` queries), and a rich-read failure recovers the remaining IDs in a single bounded source-only pass rather than falling back to per-row queries. Behavior is unchanged — a delegated child whose index row still says `webui`/`fork` while its `state.db` source is `subagent` is classified read-only exactly as before. Thanks @starship-s. (#7231)
|
||||
|
||||
- **Reopening a conversation that contains pasted or generated images no longer shows the same turn twice.** A native-image user turn is stored two ways — the rich WebUI sidecar row (with the actual image) and the Hermes Agent state row (the same turn as model-facing text, with each image replaced by `[screenshot]`) — and transcript reconciliation treated them as two different messages, so reopening the chat rendered a duplicate `[screenshot]` bubble under the real one. Reconciliation now recognizes the scalar state row as a mirror of the rich image row (matching on the exact `[screenshot]` projection, full-precision timestamp, role/tool shape, and provenance) and keeps only the rich row, so the image and its metadata survive and the duplicate is gone. It fails closed: a literal `[screenshot]` typed by the user, an ambiguous or conflicting identity match, a timestamp mismatch, or malformed content all preserve both rows rather than risk collapsing two genuinely different turns. Thanks @starship-s. (#7230)
|
||||
|
||||
- **Reopening a long conversation is now much faster — the transcript is served from a bounded cache instead of re-loading the whole session database each time, without ever showing stale content.** Reopening a large chat used to pay the full `state.db` load + merge cost even when nothing had changed; the WebUI now derives a bounded, indexed signature of the session's database (DB/WAL/SHM) plus its sidecar/lineage provenance, and reuses a cached merge when that signature is unchanged. The cache is fail-closed by construction: it is bypassed for active or pending-turn sessions, older-message paging (`msg_before`) always reads the full uncapped transcript, a state edit changes the per-target signature (so an edit to one session is never masked by an unrelated active stream), incomplete or unverifiable lineage provenance is evicted rather than reused, and any uncertainty falls back to the full load. Thanks @ruizanthony. (#7212)
|
||||
|
||||
- **The three-container Docker stack can now expose the agent gateway and dashboard for remote access without editing the compose file — and stays loopback-only by default.** The agent gateway (`8642`) and dashboard (`9119`) host-port publishes are now `${HERMES_AGENT_BIND:-127.0.0.1}` / `${HERMES_DASHBOARD_BIND:-127.0.0.1}`: unset, they bind to `127.0.0.1` exactly as before (the dashboard runs `--insecure`, so a non-loopback default would publish it on every interface); set `HERMES_AGENT_BIND=0.0.0.0` (and put the dashboard behind auth / a reverse proxy) in `.env` to reach them from another machine. The compose comments also document the real cause of a "Hermes agent is not responding" WebUI error — the gateway listener only opens when `API_SERVER_KEY` is set. Thanks @mercael91. (#7145, closes #6876)
|
||||
|
||||
- **Local model IDs that carry a colon tag (e.g. Ollama `qwen3.8:27b-mtp-q8_0`) are no longer truncated when selected.** The WebUI wraps a chosen model in its internal `@provider:model` form, and a naive colon split dropped everything after the model's own colon, so the tag/quantization suffix was lost. Model identity now resolves through the shared `@provider:model` parser, which preserves built-in, plugin, custom, `host:port`, tagged, and unqualified forms (an explicit `model_provider` still wins), keeping WebUI in parity with the models you actually run in the Hermes CLI. Thanks @darthzen. (#7183, closes #7182)
|
||||
|
||||
- **Session exports now work when the WebUI is reverse-proxied under a subpath.** The JSON/HTML/Markdown export links were built as root-relative URLs (`/api/session/export?…`), so a deployment mounted under `https://host/<prefix>/` resolved them at the host root and bypassed the deployment prefix (export 404s / wrong target). Export URLs now resolve in the browser against `document.baseURI`, so both root-mount and subpath-mount deployments build the correct link. Server API unchanged. Thanks @lowlandsheperd. (#7189)
|
||||
|
||||
- **Cancelled or interrupted compression no longer loses or misattributes transcript work.** WebUI now fences the transcript with a durable revision so a stale or partially-written snapshot can't overwrite the authoritative current turn: current-turn authority no longer reuses shifted historical indices, stale-snapshot outcomes settle terminally (no automatic retry loop), and partial output stays associated with the turn that produced it. On Hermes Agent builds without the new revision parameter the WebUI omits it and follows the unchanged legacy path (the fence activates once the companion Agent build ships), so existing installs are unaffected. Thanks @ruizanthony. (#6554)
|
||||
|
||||
- **The dashboard "loopback-only" warning no longer fires when you've configured a public dashboard URL.** The warning was keyed only off the browser's own origin, so a reverse-proxied deployment with a configured public `status.browser_url` still saw the false "only reachable on this machine" alert. It now shows only when the WebUI page origin is non-loopback **and** the resolved dashboard target is actually loopback (a shared classifier covers `127.0.0.0/8`, `::1`, IPv4-mapped-IPv6 loopback, and `localhost`/`*.localhost`), so a genuine loopback-only bind is still flagged. Thanks @webtecnica. (#6573)
|
||||
|
||||
- **Opening a new chat from a conversation now starts in that conversation's workspace, not the profile-global default.** With several conversations running in parallel on different workspaces, "New Chat" used to jump to the profile-wide default workspace instead of inheriting the workspace of the conversation you started it from. It now inherits the current conversation's workspace (an explicit profile-switch workspace still wins first, and a blank page still falls back to the profile default). If that inherited workspace directory was since deleted, the server recovers to a valid fallback instead of failing to open the chat — and only that verified same-profile inheritance is recovered, never a foreign or explicitly-typed path. Thanks @ruizanthony. (#7180)
|
||||
|
||||
- **Tool-heavy turns no longer rewrite the entire session file on every tool call.** The periodic checkpoint used to re-serialize the whole session sidecar each time a tool call completed, even when nothing it persists had actually changed — so a turn with many tool calls wrote the same bytes to disk over and over. The checkpoint now skips the rewrite when the state it would persist is unchanged (with a periodic refresh and a fail-safe that always writes if anything is uncertain), cutting redundant disk I/O on busy turns without weakening crash/reload durability. Thanks @ruizanthony. (#7172)
|
||||
|
||||
- **Clarification prompts now honor your configured `agent.clarify_timeout`, including `0` for "wait indefinitely."** The WebUI clarify bridge kept its own divergent timeout (it read only the legacy `clarify.timeout` key, fell back to 120s, and treated `0` as invalid), so the agent's "unlimited" setting was ignored and prompts could expire out from under you. It now reuses the agent's own timeout resolver, so the two stay in lockstep: `0` (or any non-positive value) means no expiry — the card waits until you answer or the run is cancelled, with no countdown — and positive values still expire and clear as before. Thanks @BBaFSE. (#7163)
|
||||
|
||||
- **Custom providers that share a model name are now routed by the provider you actually selected, so requests can't reach the wrong endpoint or use the wrong API key.** When two custom (OpenAI-compatible) providers exposed the same model id, resolution could pair one provider's endpoint with another's credential; provider slugs are now derived through a single canonical identity so overlaps are detected and each request resolves its endpoint and key from the same selected provider. Ambiguous configurations now fail closed with an actionable error instead of silently mis-routing. Thanks @rouVling. (#7107)
|
||||
|
||||
- **Dragging several files or folders into the workspace at once no longer silently drops all but the first.** The OS-drop handler read the browser-owned drop entries lazily during async traversal, but the browser only keeps those entries valid during the synchronous drop event — so every root after the first became unreadable and was skipped. The handler now snapshots all dropped files and folder entries up front, then traverses the snapshot, so multi-root drops upload completely. Thanks @nightcityblade. (#7142)
|
||||
|
||||
- **A message you sent at the moment a reply finished no longer shows up twice (out of order) after a reload.** The Hermes Agent stamps persisted messages with a `_row_id`, but the WebUI replay-merge only recognized its other row-id aliases, so the same stored row looked new and was appended again after the settled answer. Replay now recognizes the `_row_id` alias at the shared identity chokepoint, so the row is matched to its existing turn and kept in order. Thanks @ruizanthony. (#7133)
|
||||
|
||||
- **Remote (SSH/Docker) workspaces no longer fail with "Path does not exist" when the WebUI host is macOS.** A macOS host resolving a target-side Linux workspace path (e.g. `terminal.cwd = /home/<user>`) ran it through the local filesystem, where macOS rewrites the synthetic `/home` firmlink to `/System/Volumes/Data/home` and then rejects the non-existent local path. Remote terminal profiles now preserve the target-side POSIX path verbatim instead of resolving it against the host filesystem — after the same containment checks (POSIX normalization rejects `..` traversal, NUL bytes, and blocked system roots, and the path must stay within `terminal.cwd`). Local workspaces still resolve and are contained exactly as before, and the fix is host-agnostic (helps a Linux host with a remote profile too). Thanks @alfred-rootson. (#7132)
|
||||
- **Regenerating a response is now atomic — a concurrent message sent at the same moment can no longer be silently erased, and regeneration is no longer wrongly blocked on completed imported turns.** Starting a regeneration snapshots and validates session state under the session lock before mutating it, so a message that another tab/device sends during the same window survives (the regeneration cleanly returns a conflict instead of rolling back the winner), and completed WebUI-owned turns imported from a CLI/TUI/desktop session can be regenerated while malformed, read-only, or foreign final turns stay correctly rejected. The active-turn marker is now carried through the public projection so a mid-stream reload renders the pending prompt once instead of twice. Thanks @rodboev. (#6677)
|
||||
- **Large sessions no longer replay a stale duplicate prompt (or drop a real turn) when reconciling the sidecar against the model transcript.** Matching one logical turn across the two stores relied on an O(n²) fuzzy fallback that was bounded to the first 1,000 keys for performance — but above that bound a workspace-prefixed prompt (state.db stores the model-facing `[Workspace::v1: …]` wrapper while the WebUI sidecar owns the bare text) stopped deduping, so a 1,001-key session replayed the prefixed prompt after its answer. The workspace prefix is now folded into the exact key so protocol-equivalent turns match in O(1) above the cap, and — critically — that normalization is source-aware: only the model-facing state.db key strips the wrapper, while the sidecar-owned visible key preserves literal text verbatim, so a user who genuinely types `[Workspace: …]` isn't collapsed into a different turn. Thanks @Dandandad. (#7128)
|
||||
- **The Skills panel no longer shows every skill as enabled (and silently drop your other disabled skills on a toggle) when `skills.disabled` is stored as a JSON-array string.** `hermes config set skills.disabled …` and JSON-mode config saves persist the list as a quoted string (`'["skill-a","skill-b"]'` or the Python-literal `"['a']"`), which the panel read as a single literal skill name — so no skill matched, all rendered enabled, and toggling one rewrote `skills.disabled` to a one-entry list, discarding the rest. The panel read and the toggle write now decode the JSON-array/Python-literal string form into a real list (reusing the agent's shared parser when present so the two surfaces cannot drift, with a safe `ast.literal_eval` fallback), while a genuine scalar skill name still means one entry. Thanks @webtecnica. (#7120)
|
||||
- **Loading several large sessions at once can no longer spike memory into an out-of-memory crash.** Full sidecar resolution (the expensive JSON parse for a large transcript) is now bounded by a small semaphore, and concurrent requests for the same session elect a single leader that parses once while the others wait for its cached result instead of each parsing their own copy — so a burst of tabs or reconnects against big sessions can't multiply transient memory. Metadata-only reads and cache hits stay on the lock-free fast path, and a failed parse always releases its slot (no capacity leak). Thanks @Dandandad. (#7127)
|
||||
- **Reconnecting to a live chat stream now resumes from the client's last received event instead of losing the tail or replaying the whole turn.** The `/api/chat/stream` endpoint honors the SSE `Last-Event-ID` header (and `after_event_id` / `after_seq` / `replay=1`) with a resume cursor that carries provenance, so a dropped connection re-subscribes at the right place: an in-range cursor replays only the gap, an unparseable/foreign/ahead cursor fails closed to replay-from-start (never silently skipping frames), and a genuinely truncated buffer still emits `recovery_control`. A prior edge where an ahead cursor with no known snapshot cutoff installed itself as the live dedup bound — discarding every queued frame including the terminal `stream_end` and stalling the reconnect on heartbeats — is fixed by treating an unknown cutoff as fence 0 before normalization. Thanks @Paladin173. (#6886)
|
||||
- **A parked no-run approval can no longer mask the real pending approval when a gateway producer is live.** No-run "legacy" approval mirrors are now stamped with a token derived from their own live producer, and whenever a session's gateway queue has live producers the reconciler tokenizes from the authoritative head and discards any unmatched or tokenless mirror instead of letting it linger — so a stale card can't sit in front of the genuine pending approval (previously gateway mode returned 409 and permanently retained the stale mirror, and local mode reported success while resolving nothing). Tokenless-orphan retention is preserved for the no-producer case, and responding to a stale id fails without waking a different producer. Thanks @VHSgunzo. (#7086)
|
||||
- **Toggling YOLO from an approval card no longer hangs the gateway run or leaks across a session switch.** The approval card's YOLO branch now takes the same single `gateway_yolo_handoff(sid)` as the Runs dispatcher (revalidating the exact mirror after acquisition and holding it through settlement, with no nested-lock path), and the client captures one immutable owner tuple (card-owner session, approval id, load generation) at click time — failing closed without a POST when the visible card is not the active session, and fencing every post-response mutation (card hide, pending clears, stale-clear requery, controls, toast, status) on that exact tuple so a stale card can't project its result into the successor session. Thanks @dss539. (#6945)
|
||||
- **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534)
|
||||
|
||||
- **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705)
|
||||
|
||||
- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109)
|
||||
|
||||
- **Visible inline chat videos no longer appear stalled after metadata.** Every inline video stayed at `preload="metadata"`, so a currently-visible mobile player could look frozen because the browser stops loading after metadata. A shared IntersectionObserver now promotes a near-viewport video to `preload="auto"` (and immediately on play), keeps off-screen history videos at metadata to avoid buffering every historical video, and unobserves nodes removed before they intersect. No media URL, caching, or controls change. Thanks @allenliang2022. (#7076)
|
||||
|
||||
- **Profile goal contract no longer drifts across profiles on older agent builds.** On Hermes Agent builds where the context-override API was exposed but `SessionDB()` still used the import-time default DB path (v2026.5.28–v2026.7.20), a goal mutation in one profile could overwrite another profile's goal. Native profile-goal persistence is now gated on the agent supporting *both* context-home resolution *and* call-time DB-path selection; otherwise it falls back to the legacy per-profile manager. Profiles stay isolated. Thanks @ticketclosed-won. (#6899)
|
||||
|
||||
- **Packaged `hermes-webui` console entry point.** `pip install`-style wheel installs now expose a `hermes-webui` command that boots the WebUI through `bootstrap:main` — the same launcher path as `python server.py`, so agent-interpreter discovery and dependency validation run before the server starts (a direct `server:main` entry started the UI but broke every chat with a missing-dependency error). Thanks @webtecnica. (#6742)
|
||||
|
||||
- **Dotted Bedrock/Vertex model IDs get clean labels in the model picker.** Model IDs with dotted vendor prefixes (e.g. `anthropic.claude-*`) rendered awkwardly in the picker. They now get normalized display labels while the exact model IDs and provider grouping stay authoritative — distinct regional/vendor models remain separate entries, and live discovery still precedes the static fallback. Thanks @samfoy. (#6628)
|
||||
|
||||
- **MCP dev dependency pinned to a compatible range.** The MCP SDK dev dependency was unpinned, so an incompatible 2.x install broke `mcp_server.py` with an `AttributeError`. It is now pinned to a compatible 1.x range with a bootstrap guard that exits with an actionable message on an incompatible version; the SDK remains optional (imports degrade gracefully when it is absent). Thanks @webtecnica. (#6616)
|
||||
|
||||
- **Cancelling runs are no longer reattached on recovery.** A background run in the middle of cancellation could be resurrected by a reconnect/recovery reattach, producing a dying run that could double-deliver or churn. Recovery paths now exclude runs whose phase is `cancelling` (they stay busy but are not reattached), while genuinely-live runs still reattach; stale-cancelling reclamation is bounded (age ≥ 180s and absent from active streams) and idempotent. Thanks @allenliang2022. (#7096)
|
||||
|
||||
- **GPT-5.6 models expose their full reasoning range.** The four GPT-5.6 model IDs support the `max` reasoning-effort level, but the capability filter capped them below that. The ceiling is now lifted for exactly those IDs on the OpenAI-family lanes (older GPT-5 and o-series ceilings unchanged), and `max` is offered only when the authoritative configured/live catalog includes it. Thanks @boudywho. (#7083)
|
||||
|
||||
- **Test suite: two model-catalog tests are now order-independent.** `test_mtime_invalidation` depended on sibling-test cache state and `test_glm_5_3_in_models_payload_for_zai_provider` depended on the installed agent-core catalog version, so both failed spuriously under isolation/sharding. They now set up their own preconditions. Thanks @webtecnica. (#7101)
|
||||
|
||||
- **Binary files in the workspace no longer truncate when served on Windows.** The shared anchored file-open helper omitted the platform's `O_BINARY` flag, so on Windows a binary workspace file (video, image, archive) was read in text mode and truncated at its first `0x1A` (Ctrl-Z) byte — a 5.2 MB video could arrive as a few hundred bytes, breaking Range requests and playback. Non-directory anchored opens now set `O_BINARY` where the platform provides it (a no-op on Linux/macOS). Thanks @allenliang2022. (#7077)
|
||||
|
||||
- **Unstyled native form controls now use the UI font.** Bare `button`, `input`, `select`, and `textarea` elements fell back to the browser default font instead of the app's UI font, and the markdown table filter lost the UI font after its `font: inherit` shorthand. Both now route through `--font-ui`; mono, conversation, and skin-scoped controls are unchanged. Thanks @starship-s. (#6871)
|
||||
|
||||
- **Mobile navigation action shortcuts keep their rail order.** In the mobile sidebar, mirrored navigation actions could land out of order relative to the desktop rail after a native-tab or profile reorder. They now reconcile into rail-source order with no needless DOM moves. Thanks @starship-s. (#6909)
|
||||
|
||||
- **The Workspace Artifacts panel no longer keeps a stale entry after a session recovers, and no longer flashes another session's artifacts into the panel you're reading.** After a settled-session recovery the Artifacts panel could keep showing a moved or deleted artifact as a dead link until you manually switched sessions, and a background session finishing could momentarily repaint the foreground session's panel. Artifact re-projection is now scoped to the current session and pane (a background session's completion no longer touches the panel you're viewing), and the current session's panel refreshes correctly after recovery. Thanks @rodboev. (#6867)
|
||||
|
||||
- **Kanban task and board dialogs stay usable in short windows.** On a short window (short-wide landscape, small laptop, or a phone in landscape) the Kanban create/edit-task and create-board dialogs were taller than the viewport with no height cap and no internal scroll, so the dialog overflowed both the top and bottom edges and its action buttons (Create / Save / Cancel) were unreachable. The dialogs now cap their height to the viewport (`calc(100dvh - 48px)`, with a `100vh` fallback) and scroll their content internally, and the overlay uses safe centering so the top stays reachable when the dialog is taller than the window. Normal-height dialogs are unchanged. Thanks @rodboev. (#6906)
|
||||
|
||||
- **A stale approval card no longer dead-ends with "Approval response not accepted."** On the local backend, clicking a dangerous-command approval card whose approval had already been resolved or cleared could leave the card stuck showing an error, because WebUI failed to match the resolved approval back to its producer (the agent core delivers the approval as a copy that carries a `request_id` but no `approval_id`, so the existing identity/`approval_id` matches both missed and a tokenless mirror was orphaned). WebUI now also matches on the core's per-approval `request_id`, so a resolved/stale card clears gracefully. (#4948)
|
||||
|
||||
- **Docker single-container: an explicitly configured UID/GID is no longer lost, fixing a restart loop.** The entrypoint now probes the configured state-dir bind mount before the image-owned `/workspace` when auto-detecting the container UID/GID, and persists an `explicit` marker so a deliberately supplied `WANTED_UID`/`WANTED_GID` (e.g. `1024`) survives the root→user re-entry instead of being re-detected and overwritten. This fixes the documented single-container restart loop; the default stays `1024`, the two- and three-container topologies are unaffected, and a missing legacy marker falls back safely. Thanks @jorgejiro. (#7027)
|
||||
|
||||
- **The Z.AI model list now includes GLM-5.3.** GLM-5.3 was added to the Z.AI provider catalog so it's selectable in the model picker. The Z.AI onboarding default stays at GLM-5.1 until the direct API serves 5.3. Thanks @rh-id. (#7017)
|
||||
|
||||
- **A restart can no longer turn a suppressed `[SILENT]` control message into a phantom recovered turn on the `/goal` path.** The `/api/goal` endpoint now recognizes the exact reserved `[SILENT]` delivery-suppression sentinel and treats it as a successful no-op before any session lookup or goal-state mutation — matching the guard the chat-start endpoint already applies. Previously a wake relay that accidentally POSTed the sentinel to `/goal` could, after a restart, materialize it as a visible recovered user turn. Matching is exact and case-sensitive, so ordinary goals are unaffected. Thanks @webtecnica. (#7019)
|
||||
|
||||
- **A busy subagent tree no longer promotes an active child to a misleading top-level sidebar row.** The session sidebar builds from a bounded oversampled candidate window; after the recency slice, subagent parents that were present in the candidate set could be dropped, leaving their still-running child leaves rendered as if they were their own top-level conversations. Those parents are now re-added after the slice (bounded, cycle-safe, subagent-scoped so WebUI ancestors stay excluded), so the delegated-session tree nests correctly. Thanks @carlotestor. (#7031)
|
||||
|
||||
- **A shared conversation link can no longer fabricate appearance state that overrides an operator's deployment default.** The public share page (`share.html`) now applies the same first-visit appearance guard as the main app bootstrap (and picks up two skin IDs it was missing), so opening a shared link on a fresh browser no longer writes an explicit theme/skin into local storage that would then be treated as a deliberate user choice. Public-share rendering is unchanged. Thanks @webtecnica. (#7030)
|
||||
|
||||
- **Docker: the repository's own agent-instructions file is no longer baked into the runtime image.** A root-anchored `/AGENTS.md` entry was added to `.dockerignore` so the repo-root `AGENTS.md` (maintainer/build guidance, not user content) doesn't get copied into the Docker runtime and injected into the agent's project context. Nested workspace `AGENTS.md` files remain available exactly as before. Thanks @rodboev. (#6853)
|
||||
|
||||
- **Docker: the bundled SQLite is upgraded to 3.53.0 to fix a WAL-reset corruption bug (and still erases deleted rows).** The `python:3.12-slim` base image ships SQLite 3.46.1, which is vulnerable to the March 2026 WAL-reset corruption bug, and Debian hasn't backported the fix — so the image now compiles SQLite 3.53.0 from the official amalgamation (SHA-256 pinned and verified before extraction, FTS5/FTS4/R-Tree enabled, build tools purged afterward). The source build is also compiled with `SQLITE_SECURE_DELETE` and asserts `PRAGMA secure_delete = 1` at build time, so deleting a conversation still overwrites its content on disk exactly as the base image did (a from-source build without that flag would have left deleted transcript bytes recoverable in `state.db`). Only affects the Docker image. The source-built library is registered in `ld.so.conf.d` so it loads ahead of the base image's copy on every architecture (including arm64, where `/usr/local/lib` isn't on the default loader path). Thanks @qxxaa. (#6900, #7044)
|
||||
|
||||
- **Foregrounding a long or busy conversation no longer risks running the browser out of memory.** Switching back to a tab with a very long active chat could make the browser re-materialize the entire transcript's structured data at once — on the largest sessions this ballooned memory usage into the multi-gigabyte range and could OOM-kill the tab or the whole machine. The catch-up load on tab focus is now coalesced, the render window growth is bounded, and full structured-value allocations for off-screen turns are dropped, so foregrounding a long chat stays lightweight. A session-updated notification that arrives while a refresh is already in flight is now latched and reconciled once the refresh finishes (instead of being dropped), and the render cache signature now distinguishes tool-argument order the way the rendered output does (so differently-ordered arguments can't reuse stale cached markup). Thanks @webtecnica. (#7006, #6999)
|
||||
|
||||
- **The composer no longer keeps a "Reconnected" status pill sitting next to the send button.** When a live response reconnected after a brief drop, the small "Reconnected" notice was written into the composer's status slot and stayed there indefinitely — on a narrow phone layout it crowded (and could slightly clip) the send button and the other composer controls. Transient stream notices now auto-dismiss on their own: "Reconnected" clears after one second and a provider-fallback notice after four, so the notice flashes briefly and then gets out of the way. The status setter uses a single owned timer that cancels any prior countdown before arming a new one, so a newer status can't be wiped early by an older one, and a status set without a timeout (a persistent one) is left in place exactly as before. Thanks @starship-s. (#7028)
|
||||
|
||||
- **Self-hosted appearance defaults are reachable again on a fresh browser.** The inline theme bootstrap wrote the resolved theme/skin back to local storage on every load — including a brand-new browser with no saved preference. That made the settings sync treat the fallback (`dark`/`default`) as an explicit user choice and ignore the server-side appearance default, so operators who changed the default appearance for their instance never saw it take effect on first visit. The bootstrap now persists only when a saved preference already exists; a fresh browser keeps the exact same first paint but lets the server default win. Thanks @tomtong2015. (#6808)
|
||||
|
||||
- **Japanese locale: consistent wording for "profile".** The Japanese UI used two different katakana spellings for "profile" — the cron profile selector said プロフィール (the personal/social-profile loanword) while the rest of the app (profiles tab, kanban, settings) said プロファイル (the technical/config term). The two cron strings are now unified to プロファイル, matching the rest of the Japanese locale and the Hermes docs. Thanks @0809android. (#6762)
|
||||
|
||||
- **A stray `[SILENT]` control message can no longer turn into a phantom chat turn.** Cron agents use the exact response `[SILENT]` as a delivery-suppression sentinel; if an external wake relay accidentally POSTed that sentinel to the chat API and the agent restarted while the turn was pending, session recovery could materialize it as a visible (recovered) user message that reappeared on every restart. Both chat-ingress entry points now recognize the exact reserved sentinel and treat it as a successful no-op before any session lookup or pending-state change. Matching is exact and case-sensitive, so ordinary messages are unaffected. Thanks @allenliang2022. (#7018)
|
||||
|
||||
- **A busy Kanban board no longer pushes your regular conversations out of the sidebar.** The sidebar builds its session list from a bounded 20-row candidate window, and Kanban runs were competing in that window — so on a profile with 20+ recent Kanban runs, those rows could fill the window and evict your CLI / TUI / ACP / messaging conversations from the list. Kanban now gets its own dedicated, bounded recovery pass (the same pattern cron and webhook sessions already use), so a high-volume Kanban worker can't crowd out interactive conversations. Explicitly filtering the sidebar to Kanban still shows them normally. Thanks @zicochaos. (#6998)
|
||||
|
||||
- **The workspace file-sort menu is now fully translated in all 14 non-English languages.** The six sort options (Sort by, Name A→Z / Z→A, Date created / modified, and the "creation time unavailable" note) were still showing English placeholder text in the non-English locales after they were added in #6091; they're now translated into German, Spanish, French, Italian, Portuguese, Czech, Turkish, Polish, Russian, Japanese, Korean, Chinese (Simplified and Traditional), and Vietnamese. Thanks @webtecnica. (#6910)
|
||||
|
||||
- **Streaming text can no longer render into the wrong conversation when you switch sessions mid-response.** If one session was still streaming and you switched to another, a render that had already been scheduled could fire after the switch and briefly write the first session's tokens into the second session's pane. The live-stream render path now re-checks that its session is still the active one right before it writes to the DOM (including inside the deferred animation-frame/timeout window), so a scheduled render is dropped instead of leaking into the wrong pane; the streamed text is preserved and re-renders correctly when you switch back. Thanks @webtecnica. (#6502)
|
||||
|
||||
- **Jumping to the first response no longer snaps back to the bottom while the answer is still streaming.** When you tapped "jump to answer" on the first response, the view scrolled to the answer but then got yanked back down as more of the response streamed in, so you couldn't read from the top. The jump now takes ownership of the scroll position and holds it where you put it — it only releases when you explicitly scroll down, press End, or switch sessions. Normal bottom-following (when you haven't jumped) is unchanged. Thanks @pxxD1998. (#6621)
|
||||
|
||||
- **A provider that hit its credit limit (HTTP 402) recovers in ~2 minutes after you top up, instead of staying greyed out for an hour.** When a pay-as-you-go provider returned 402 (Payment Required), the WebUI marked it unavailable for a full hour, so even after you added credits it stayed unusable for up to 60 minutes. The WebUI now derives the 402 cooldown from the installed agent runtime's own credential-pool contract rather than hard-coding it, so its "is this provider usable yet" decision always matches what the runtime will actually lease (no window where the UI offers a provider the runtime still refuses). On a runtime that ships the shorter 402 cooldown the wait drops to ~120s; on an older runtime it safely keeps the previous one-hour behavior. Thanks @webtecnica. (#6626)
|
||||
|
||||
- **Editing your transcript (truncate, retry, or undo) no longer risks the deleted messages coming back.** When you intentionally shrink a conversation, the on-disk session ends up with fewer messages than its `.bak` sidecar — and the session-recovery inspector used to read that as suspected data loss and recommend restoring the backup, resurrecting the messages you just removed. The session now stamps a one-time provenance marker whenever you deliberately shrink a transcript, and recovery treats a valid, current marker as "this shrink was on purpose" and leaves it alone. The marker is validated strictly (a malformed or missing marker fails closed to the original data-loss safeguard), and a genuine loss — where the backup is newer than your last intentional edit — still triggers recovery exactly as before. Thanks @franksong2702. (#6954)
|
||||
|
||||
- **Multi-container Docker: the WebUI can now reach the agent gateway API without hand-editing compose files.** The two- and three-container Compose topologies now forward the gateway API server settings end to end — `API_SERVER_ENABLED`/`API_SERVER_HOST`/`API_SERVER_KEY` into the agent container and the matching `HERMES_WEBUI_GATEWAY_API_KEY` into the WebUI container — so setting `API_SERVER_KEY` in `.env` is enough to enable Tasks/cron and gateway-backed features (the agent only binds port 8642 when the key is a usable value). Password vars are now `${HERMES_WEBUI_PASSWORD:-}` substitutions (set once in `.env`, no compose edit), and a new `.gitattributes` forces LF on shell scripts so a Windows `core.autocrlf` checkout no longer breaks the image build (`exec …init.bash: no such file or directory`). Thanks @Yang-qwq. (#6917)
|
||||
|
||||
- **The chat view no longer bounces vertically while a reply streams in.** As each word faded in during streaming, the browser replaced the finished word's DOM node with a fresh text node — and every replacement made the browser's native scroll anchoring re-pick an anchor while the transcript was still growing, producing a visible vertical bounce. The two fade-cleanup handlers now keep each faded-in word's node stable (they just clear the transient animation class and timing variable) instead of replacing it, so the scroll position stays put. The settled reply is still rebuilt as plain text exactly as before, and text selection during streaming is steadier as a bonus. Thanks @ruizanthony. (#6257)
|
||||
|
||||
- **The model selector no longer lists providers whose API key you've removed.** A provider that stayed in `config.yaml` with only metadata (e.g. `openai-api: {name: "OpenAI API"}`) kept appearing in the chat model dropdown after its credential was removed, so picking it failed. A config-listed provider now appears only when it was actually detected from a credential source (env var, hermes auth, or the credential pool), with provider aliases resolved on both sides (so `x-ai`/`xai`, `google`/`gemini` match correctly). Route-configured and active-model providers are unaffected. Thanks @webtecnica. (#6338)
|
||||
|
||||
- **Links ending in Chinese/Japanese full-width punctuation no longer swallow the punctuation into the URL.** When an assistant wrote a URL followed immediately by a CJK full-width mark (`)。,;:!?、`) — common in Chinese/Japanese prose — the autolink pass treated the mark as part of the link, so the rendered href included the trailing punctuation (and often 404'd). Both autolink passes now strip a single trailing CJK full-width mark the same way they already strip ASCII `.,;:!?)`, and a full-width close-paren also terminates the URL. ASCII behavior is unchanged. Thanks @happy5318. (#6792)
|
||||
|
||||
- **Mobile: create forms opened from the sidebar drawer are no longer hidden behind it.** On phones the sidebar is a full-screen drawer over the main view; opening a new cron/skill/workspace/profile form from inside it left the drawer covering the form, so the tap looked like a no-op. The panel re-open on a rail tap now happens synchronously (before the panel's async data load, so a form that closes the drawer isn't re-covered), and the four create-form openers now close the drawer the same way their matching detail views already did. Desktop is unaffected. Thanks @silent-reader-cn. (#6921)
|
||||
|
||||
- **Mobile: creating a conversation from a project chip now shows it instead of leaving the drawer open over it.** The project-chip quick-create ("+") created the new project conversation but never closed the full-screen mobile sidebar drawer, so on phones the new session was created but hidden underneath. The drawer now closes after the session is created and the sidebar repaints (the failure path keeps it open so the error toast stays visible for retry). Desktop is unaffected. Thanks @silent-reader-cn. (#6933)
|
||||
|
||||
- **Mobile: an open extension panel stays active when you reopen the sidebar drawer.** Reopening the mobile drawer treated an active extension panel view as the Chat panel, so the extension's sidebar navigation state was deactivated every time you opened the hamburger/edge drawer — the frame stayed behind the drawer but had no reachable navigation on the phone. The drawer-sync helper now recognizes an active extension view (`showing-x-<token>`), keeps its sidebar view active, and does so without corrupting the host panel state machine. Thanks @rodrigogs. (#6671)
|
||||
|
||||
- **Switching a session between remote (SSH) and local terminal backends no longer reuses a stale cached agent.** The cached-`AIAgent` reuse signature omitted terminal-backend identity, so a session that changed its terminal backend could be served a cached agent still carrying the previous backend's terminal env. The signature now includes the terminal env / SSH host / SSH user, so a backend change forces a fresh agent (matching how a profile change already does). Thanks @webtecnica. (#6175)
|
||||
|
||||
- **Historical chat media previews no longer change when a file is overwritten in place.** After the recent switch to revalidating `/api/media` responses, overwriting a file with the same name (e.g. regenerating `chart.png`) silently rewrote every earlier chat preview that referenced it, so you lost the ability to compare the old and new versions. Each settled message now freezes the bytes of its local-file previews into a content-addressed snapshot store and serves those frozen bytes for that message, while new turns still show the current file. The snapshot store is internal (never reachable as a normal media path), bounded (50 MB per file, 2 GB total, oldest-evicted-first), and each stored digest is bound to the exact source path it was captured from, so a snapshot can only ever be served for its own message. Thanks @happy5318. (#6979)
|
||||
|
||||
- **Multi-container Docker deployments can reach the agent API again.** In the two- and three-container Compose topologies, the agent's gateway API bound to loopback only, so the WebUI (and dashboard) containers — separate hosts on the Docker network — couldn't reach `http://hermes-agent:8642` even with `HERMES_API_URL` and `API_SERVER_KEY` set correctly, breaking cron/Tasks and gateway-backed features. The agent service now sets `API_SERVER_HOST=0.0.0.0` so it's reachable across the Compose network; the host port publish stays `127.0.0.1`-only, so this does not expose the API beyond the Docker network and host loopback. Thanks @bragov4ik. (#6968)
|
||||
|
||||
- **Two WebUI sessions with the same auto-generated title no longer leave the second one blank.** Follow-up to the `state.db` title sync: hermes-agent enforces a uniqueness rule on session titles, so when two sessions independently generated a byte-identical auto-title, the second sync raised (and silently swallowed) a collision error and left that session untitled in `hermes sessions list`. The sync now catches the collision, derives a de-duplicated variant via the existing lineage helper (`"My Session"` → `"My Session #2"`), and retries — and because the retry goes through the same provenance guard, a title you set manually is still never overwritten. Thanks @webtecnica. (#6964)
|
||||
|
||||
- **WebUI no longer crashes a send when running against an older hermes-agent.** The streaming path passed `persist_user_timestamp=` to `agent.run_conversation()` unconditionally, so a WebUI paired with an older agent whose `run_conversation()` predates that keyword raised `TypeError: unexpected keyword argument` and the message failed. The three call sites now check the callable's signature first (via a small `_supports_kwarg` introspection helper) and only pass the timestamp when the agent accepts it — the same defensive pattern already used for `moa_config`. Modern agents are unaffected (the timestamp is still passed with the identical value); older agents degrade gracefully instead of erroring. Thanks @harsh2hell. (#6935)
|
||||
|
||||
- **`hermes sessions list` no longer shows blank titles for WebUI sessions.** Background title generation wrote the auto-title to the WebUI session sidecar but never to hermes-agent's `state.db`, so the CLI/TUI session list showed those WebUI sessions untitled. The title is now bridged into `state.db` after it's generated (and on adaptive refresh) via `set_auto_title_if_empty`, which respects title provenance — a name you set manually via CLI/Gateway/TUI (`user` source) is never overwritten by the auto-title, an already-auto-titled row stays put, and an existing session's `source` is preserved (the sync creates the row with INSERT-OR-IGNORE semantics). Thanks @liuguangyong93. (#6892)
|
||||
|
||||
- **No more phantom "Compressing context" barrier after switching sessions.** The compression UI state is per-session, but `loadSession()` did not clear it on a switch, so a compression card from a prior session could leak across the load and appear as a phantom "Compressing context" barrier on a fresh session that never triggered compression. Switching sessions now clears the compression UI (state, elapsed timer, composer session-lock, and barrier element) alongside the other per-session resets, before the transcript loads. A compression genuinely running on the session you switch *to* is unaffected — it re-surfaces from that session's own live event stream (and manual compression from its server job status). Thanks @happy5318. (#6938)
|
||||
|
||||
- **Served media/files now revalidate with an ETag, and a mid-transfer error can never corrupt the response.** File serving (`_serve_file_bytes`) now sends an `ETag` and honors `If-None-Range`/`If-None-Match` so an unchanged image or attachment returns a cheap `304` instead of re-sending the whole body. The transfer path is also hardened: once the status line and headers are committed, any body-write failure — a client disconnect *or* a non-disconnect error like a truncated read, generic `OSError`, or `PermissionError` — is contained and logged at debug instead of escaping to the request handler, where it would have emitted a second `500` status line after the committed `200`/`206` and corrupted the HTTP stream. Thanks @happy5318. (#6922)
|
||||
|
||||
- **Layout test helper no longer flags scroll-reachable fields as off-viewport.** The shared `assert_layout_sane` degenerate check reported a false "interactive element is off-viewport" for an input that sits below the fold at rest but is reachable by scrolling an `overflow-y:auto` ancestor (e.g. a tall dialog on a short landscape viewport). It now exempts elements whose off-viewport edge is absorbed by a scrollable ancestor, while still flagging genuinely-unreachable elements (no scroll escape). The Kanban board settings modal layout test also drops its synthetic 480×320 short-landscape case (a viewport no real device uses, whose sub-fold geometry was environment-marginal and CI-flaky); real desktop/tablet/narrow viewports still assert the layout. (internal test infra)
|
||||
|
||||
- **Screen readers no longer announce multiple sidebar panels as "expanded" at once.** Switching between rail panels (Chat → Kanban → Skills → …) set `aria-expanded="true"` on each newly-active rail button without clearing it on the previous one, so the attribute accumulated and assistive tech announced several open disclosures simultaneously. The active-panel sync now clears `aria-expanded` on all rail nav buttons before marking the single active one, so exactly one button reflects the sidebar's open/collapsed state. Thanks @rodrigogs. (#6656)
|
||||
|
||||
- **User-uploaded images now follow the session's actual model, not the global default.** The routing decision that picks whether an uploaded image is embedded natively (as `image_url` parts) or routed through the text/vision-analyze pipeline was resolved from the global config default, ignoring the model the current session actually uses. A session switched to a text-only model would still have images forwarded natively to a model that can't see them, and a session on a vision-capable model could be degraded to text if the global default happened to be text-only. The session's resolved provider/model (including the pre-canonicalization `custom:slug` identity) is now threaded through the routing decision, and the credential-heal retry path preserves that identity so a text-only custom model keeps stripping historical images across an auth retry. No behavior change when no session identity is present (global default, as before). Thanks @happy5318. (#6882)
|
||||
|
||||
- **Running cron jobs no longer look "completed" in the sidebar mid-run.** A session driven by a still-running cron job could flip to a completed/unread state the moment the job appended a message, because cron liveness is only exposed via `/api/crons/status` (which the sidebar never polls). The session-list now stamps a server-side `cron_running` flag so the sidebar keeps the row active and defers its completion/unread transition until the job actually ends. Job attribution is exact — a session is matched to the longest running job-id prefix and only when the text after it is a real run timestamp, so a running `backup` job can't claim a `backup_full` session (or vice-versa), and older runs of the same job stay completed. Thanks @webtecnica. (#6734, #6728)
|
||||
|
||||
- **WebUI now honors `auxiliary.title_generation.enabled=false`.** When automatic title generation was disabled in config, the WebUI still auto-generated session titles via the auxiliary model (background title update, background refresh, and manual regeneration all ignored the setting). All three paths now respect the flag, matching the Hermes agent's canonical contract exactly (`is_truthy_value`, default on: a string enables only for `1`/`true`/`yes`/`on`, and an absent value stays on). Manual/explicit renames are unaffected. Thanks @X-Shihai. (#6877, #6814)
|
||||
|
||||
- **The "Auto-follow new content" setting now survives a settings-fetch failure.** If you turned auto-follow OFF, a transient failure to load settings at boot (server restart, slow network) used to silently reset it back ON for the whole session, causing post-turn scroll yanks until the next refresh. The resolved value is now mirrored into local storage on every authoritative settings resolve (boot, autosave, settings-panel load) and the boot-failure fallback reads that mirror instead of hardcoding ON — so an explicit OFF is honored even when the fetch fails. The mirror is only written from an explicit server value (never a synthesized default), and a fresh install still defaults to ON. Thanks @sand01chi. (#6856, #6819)
|
||||
|
||||
- **A cancelled turn can no longer overwrite the next turn's result.** When you stopped a streaming turn and immediately started another, a delayed finalizer from the cancelled worker could write its stale output back over the successor turn's already-persisted result, corrupting the session. The finalizer now fails closed unless it still holds current writeback ownership (checked under the session lock), so only the live turn's worker can persist. A companion memory leak is also fixed: Gateway-backed runs now release their writeback-ownership entry on teardown and pre-start cancellation instead of leaving one entry per session for the process lifetime. Thanks @webtecnica. (#6636, #6623)
|
||||
|
||||
- **Assistant messages no longer flash/re-fade the whole message when a tool call runs mid-stream.** In transparent-stream mode with the text-fade effect on, stripping a tool call's XML from the live prose rewinds the displayed text, which forced the renderer to rebuild the message node and replay the fade-in on every already-visible word at once — a full-message blink on each tool call. Already-rendered text now stays put; only genuinely new text past the rewind point fades in. Thanks @silent-reader-cn. (#6783)
|
||||
|
||||
- **Branching a session no longer cuts the fork short or drops the assistant conclusion you selected.** The branch/fork operation sliced its `keep_count` boundary against the raw stored transcript, but that count is computed by the frontend against the merged display view (`GET /api/session`). When the two differ — replayed sidecar/state.db duplicate rows, filtered prefixes, compression-continuation stitching — the fork cut too early, sometimes stopping mid tool-run and omitting the final answer. The fork now rebuilds its source transcript through the exact same display-merge path the frontend counts against, so the slice lands on the row you actually chose. Thanks @ruizanthony. (#6735)
|
||||
|
||||
- **A phantom "Compressing context" barrier no longer sticks in the transcript when a compression event is lost or delayed.** If the automatic-compression `compressed` SSE event was dropped or arrived late, the compression UI could stay stuck in its running state after the turn already finished, leaving a permanent "Compressing context" placeholder. When a turn reaches its terminal `done` while the compression UI is still running, that stale state is now cleared through the canonical teardown, covering both the session-rotating (A→B) and non-rotating (A→A) compression paths. Thanks @soria-clawd-bot. (#6572)
|
||||
|
||||
- **Repeated completions in the same session now raise a fresh notification instead of silently updating one in the background.** Same-session desktop/PWA notifications share a per-session tag; without re-notification, a second completion silently replaced the first in the OS notification center (Windows Action Center) with no new alert. Same-tag notifications now re-alert, so each completion is surfaced. Thanks @webtecnica. (#6681, #6673)
|
||||
|
||||
- **A stale browser tab can no longer silently revert your update channel.** The Settings → System update-channel selector (Stable / Experimental) was included in the generic Preferences autosave payload, so a tab left open on an old channel value would overwrite a newer channel selection made in another tab whenever any unrelated preference autosaved. The channel now has its own dedicated, sequenced write path (it is no longer part of the generic autosave or the manual Save Settings body): changing the selector persists only `update_channel`, re-syncs the selector and version badge from the server-confirmed value, and reverts to the last confirmed channel if the save fails. All Settings-panel saves are additionally serialized through a FIFO queue so one response cannot race the next read-modify-write. Thanks @rodboev. (#6666, #6612)
|
||||
|
||||
- **An async delegation (background task) completion is no longer silently dropped when its origin session is busy.** When a background delegation finished while the origin WebUI session was running a foreground turn, the delivery path repeatedly claimed and released the Agent core's durable-delivery record every half-second; because the core increments a finite attempt counter on every claim and terminally drops the eighth, a normal completion could vanish after only a few seconds of overlap with an active turn. The completion is now left pending and unclaimed while the session is busy and retried through the shared restore sweep once the session can accept a wakeup, so the durable record stays authoritative and the attempt budget is spent only on genuine delivery attempts. On legacy (pre-durable) Agent cores a known-but-busy target keeps backing off until it can accept the completion instead of being dropped after one retry, while a genuinely unroutable or malformed completion still stops after one bounded attempt (no infinite loop). Thanks @nesquena-hermes and @ai-ag2026. (#6632, #6662, #6601)
|
||||
|
||||
- **The gateway sidebar watcher no longer wipes session state on a transient database hiccup, and its 5-second poll is cheaper on large histories.** The watcher's every-5-second fingerprint aggregate read `m.role`, which forced table-page access for historical message rows even when the `(session_id, timestamp)` message index could otherwise cover the query — expensive on a large `state.db`. The hot fingerprint now stays index-covered (`COUNT(m.id)` + `MAX(m.timestamp)` keeps same-count rewrite detection), a query-plan check on a 1.07 GB database confirms `USING COVERING INDEX` (≈20.9 ms cold, 1.1–1.4 ms warm), and a bounded full projection every 60 seconds keeps role-only sidebar visibility changes eventually consistent. Separately, a transient projection failure is now distinguished from a genuinely empty result, so a database error preserves the prior sidebar state and every watcher checkpoint instead of blanking the list, and no empty initial snapshot is published before `state.db` exists. Thanks @stefanpieter. (#6821, #3506)
|
||||
|
||||
- **A configured default theme/skin now actually reaches a brand-new user with no settings file.** `load_settings()` read the appearance keys through an `isinstance(stored, dict)` arm, but a *missing* `settings.json` yields `{}` (still a dict), so `stored.get("theme")` was `None` and normalization fell through to a hardcoded `dark`/`default` — making the configured `_SETTINGS_DEFAULTS["theme"]`/`["skin"]` unreachable for exactly the case they exist to serve. This was invisible on the stock `dark`/`default` values (both paths agree) but meant any deployment or fork that changed the appearance defaults still handed every new user `dark`. The fallback is now gated on whether an appearance key is actually stored (the pair, not per field — so a stored legacy theme with no skin keeps its migration instead of having the default skin injected), so a truly-empty profile gets the configured defaults. Pairs with #6808 (the boot script no longer fabricates an explicit choice, letting the server default win). Thanks @tomtong2015. (#6834, #6808)
|
||||
|
||||
- **The profile dropdown no longer serves stale rows sooner than intended.** The 4-second cache behind the profile list stamped its timestamp *before* the profile-row build (an `os.walk` across every profile's skill tree) rather than after, so the TTL clock started early — the cache effectively expired sooner than 4s by however long the build took, and a build slower than the TTL could produce an entry that was already stale on arrival. The timestamp is now taken after the rows are built (still inside the single-flight lock), so the full TTL measures from when the data actually became valid. Thanks @starship-s. (#6846)
|
||||
|
||||
- **Switching the workspace mid-session no longer rewrites the system prompt or invalidates the LLM prefix cache.** The active workspace (`s.workspace`) updates on every turn when you switch workspaces via the WebUI header dropdown, and that live value was interpolated into the system prompt (`msg[0]`) — so a mid-session switch mutated `msg[0]` and invalidated the model's prefix cache (APC / Radix Tree) for the entire transcript, silently re-billing and slowing every subsequent turn. Sessions now freeze an immutable `created_workspace` snapshot at creation and interpolate that (never the live workspace) into the system prompt, keeping `msg[0]` byte-stable across switches; the active workspace still reaches the model through the `[Workspace::v1: /path]` tag on the current user turn, so file operations continue to target the workspace you switched to. Legacy sessions with no persisted snapshot fall back to their recorded workspace. Thanks @webtecnica. (#6683, #6672)
|
||||
|
||||
- **You can now scroll up to read earlier content while a response is still streaming.** During an in-flight programmatic (auto-follow) scroll, the programmatic-scroll guard returned before the native scroll listener could see the event, so a gentle upward wheel (a small `deltaY` above the ordinary −30 sticky-unpin threshold) was swallowed and live-tail follow kept yanking the reader back to the bottom. A `_freshProgrammaticScrollActive()` guard (a 150 ms validity window on the programmatic-scroll flag, refreshed by every programmatic scroll writer) now lets even a small upward wheel during a fresh programmatic scroll immediately stop auto-follow, so scrolling up mid-stream sticks. The ordinary −30 direct threshold and the touch/moved-up branches are unchanged, and the re-pin-only-at-true-bottom (#4295), gentle-wheel-intent (#4970), and footer-jitter (#4346) behaviors are preserved. Thanks @franksong2702. (#6453, #6414)
|
||||
|
||||
- **The just-settled activity Worklog no longer flickers/resets when a streamed turn finishes.** On stream completion the transcript was rendered once with the turn's Worklog forced open, then a second whole-transcript rebuild ran immediately just to collapse that Worklog — the second rebuild tore out the DOM the user had just seen and exposed a brief reset/flicker frame. Stream completion now collapses the single settled Worklog **in place** (keeping the canonical first render), deferring row removal to a `requestAnimationFrame` only after the group is hidden and preserving nested detail-disclosure state; the rows stay recoverable from the settled transcript via the existing deferred-row path, and a missing owner/identity safely falls back to the old full render. Error/cancel/disconnect cleanup remains destructive as before. The collapsed Worklog rail's tap target is also enlarged to clear 44px on touch devices. Thanks @franksong2702. (#6454, #6414)
|
||||
|
||||
- **`@provider:model` picker selections no longer 404 against the Hermes gateway.** The WebUI model picker deliberately keeps the qualified `@provider:model` form for internal routing (#1253), but nothing converted it back into a bare model name on the gateway request path, so the `@`-prefixed string was forwarded verbatim to the upstream provider API and 404'd with "Unknown provider" / "model not found" for any model not covered by a `model_routes` workaround entry. The gateway request body (in both the runs-API and legacy-chat streaming paths) now carries the bare model name via a new `_gateway_model_field()` helper, and provider normalization in `_clean_session_model_provider()` resolves the provider through the **shared** `_parse_provider_qualified_model_id()` grammar instead of a positional colon split — so a multi-segment custom provider ID (`@custom:<slug>:<model>`, `@custom:<host>:<port>:<model>`) yields the real model rather than a fragment, while plain (non-`@`) provider IDs whose colons belong to the ID itself are preserved verbatim. Thanks @dss539. (#6723, #6722)
|
||||
|
||||
- **Custom providers with a dict-shaped `models` catalog now show every model in the Providers card.** The Providers settings card built a custom provider's model list from only the singular `model` field or a plain `models` list, so a provider configured with a dict-shaped `models` catalog (e.g. a LiteLLM gateway with 20 models) showed just the single `model` entry while the chat model picker already listed the full catalog. The card now builds its list the same way the picker does — the singular `model` first, then the remaining catalog IDs appended via the shared `_configured_model_ids` helper (which strips whitespace, drops empty IDs, and de-duplicates, including when the singular model is also a dict key) — so the two stay consistent. String-shaped `models`, providers without a `models` block, and empty/whitespace-only configs are unchanged. Thanks @KimSpecht. (#6774)
|
||||
|
||||
- **Matrix gateway sessions are now correctly grouped under Messaging.** Sessions originating from the Matrix gateway (`Platform.MATRIX`) were not listed in the messaging-source allowlists, so they fell into the default/other bucket in the sidebar instead of being grouped and labeled as messaging sessions like Telegram, WhatsApp, WeChat, and the other chat platforms. `matrix` (labeled "Matrix") is now added to the messaging-source allowlists on both the Python (`MESSAGING_SOURCES` / `SOURCE_LABELS`) and JavaScript (`_MESSAGING_RAW_SOURCES` / `_MESSAGING_SOURCE_LABELS`) sides, keeping the two in sync; no other source's classification changes. Thanks @rudidev08. (#6816)
|
||||
|
||||
- **Session recovery/discoverability polling no longer leaks SQLite file descriptors.** Three read-only `state.db` probes in the session recovery and discoverability paths opened their connections with a bare `with sqlite3.connect(...) as conn:` block — but SQLite's connection context manager only manages the transaction, it does not close the connection, so each poll cycle left the connection (and its file descriptor) open until the cyclic garbage collector eventually reclaimed it. Under repeated background polling this could exhaust the process file-descriptor limit (notably on macOS) and then block further session/file writes. Each probe now wraps the connection in `contextlib.closing(...)` so the descriptor is released deterministically at block exit; the read logic is otherwise unchanged. Thanks @randmars. (#6823)
|
||||
|
||||
- **The memory panel now honors `memory_enabled` and `user_profile_enabled`.** The Settings → Memory read/write endpoints previously loaded and saved `MEMORY.md` / `USER.md` regardless of those config flags, so a profile that disabled memory or the user profile still exposed and mutated the files. Both endpoints now read the flags from the per-profile config snapshot (`config.memory.memory_enabled` / `config.memory.user_profile_enabled`): a disabled section is omitted from reads and rejected with a 403 on write, while the default-on behavior is preserved when the flags are absent. Thanks @webtecnica. (#6498, #6406)
|
||||
|
||||
- **When a turn hits the tool-call limit, its final written answer now renders alongside the limit status card instead of being replaced by it.** In the ordinary (non-transparent) transcript branch, the presence of the tool-limit status card was counted as the turn's "visible body," so a genuine final assistant report was suppressed and the user saw only the status card (whose own subtitle promises "the written answer below"). The status card no longer counts as the visible body, so a non-empty report renders together with the card and picks up the standard message footer/actions; status-only turns (no report) still show just the card, and card ordering is unchanged. Thanks @rodboev. (#6486, #6467)
|
||||
|
||||
- **A mid-run reload no longer briefly shows the active turn's prompt twice.** While a turn is live the server can reconcile the current user row into the returned transcript (via the state.db merge) while `pending_user_message` is still set, and that row is then followed by the same turn's assistant/tool output — so the strict tail scan reported "no current user row" and the client materialized the pending prompt a second time, showing a duplicate user bubble until the settle render. The client now also matches the active turn's row by exact identity — the server-stamped `_active_turn_token` (`stream_id:started_at`) or a timestamp equal to `pending_started_at` within a precision-only epsilon — and adopts it instead of re-rendering. Matching is identity-only, never proximity: a rapid same-text repeat (e.g. sending "continue" twice ~1s apart) is treated as ambiguous and still materializes as its own turn, so a legitimate repeated prompt is never swallowed. Thanks @happy5318. (#6670)
|
||||
|
||||
- **"Force recovery" now repairs a dirty Stable checkout that has no promoted release ref.** On the Stable channel a WebUI checkout with local modifications but no promoted ref to move to previously reported "already up to date" and did nothing, leaving the working tree stuck. Force recovery now detects a *confirmed* dirty tree (strict `git diff-index` exit-1 only — a timeout or probe failure is treated as unknown and never triggers a destructive reset) and discards the local changes by resetting to the current `HEAD`, so the commit itself never moves and the channel is unchanged. Clean or unknown checkouts stay a no-op, the ancestor-rewind guard is preserved, and ignored build/cache artifacts survive (no `git clean -x`); a `git clean -fd` failure (e.g. a Windows reserved-name file, #4914) stays non-fatal. Thanks @rodboev. (#6688)
|
||||
|
||||
- **OIDC-only deployments get a clear startup warning for ambiguous whitespace `allow_values`.** When `webui_oidc.allow_values` (or `HERMES_WEBUI_OIDC_ALLOW_VALUES`) was given as a scalar with internal whitespace like `"alice@example.com bob@example.com"`, whitespace is not a separator so the whole string is treated as one allow-list entry — silently locking out the intended users. Startup (and OIDC resolution) now emit a warning naming the ambiguity and pointing to the comma-delimited scalar or YAML-array forms, while a genuinely intentional multi-word group is called out as already correct. Parsing and enforcement are byte-for-byte unchanged — this is a diagnostic only, it never broadens authorization. Thanks @rodboev. (#6663, #6603)
|
||||
|
||||
- **Inline HTML previews no longer show stale content after an edit.** The `/api/media` HTML response and the inline-preview `fetch()` used a 1-hour private cache, so an agent-edited HTML file kept showing the old render until a manual full-page refresh. HTML responses now send `Cache-Control: no-store` (non-HTML media keeps its `private, max-age=3600`) and the inline fetch passes `{cache:'no-store'}`, so a re-opened preview always reflects the current file. Thanks @happy5318. (#6706)
|
||||
|
||||
- **CLI sessions (Claude Code, Codex) are readable again on named profiles.** External-agent transcripts scanned straight from `~/.claude/projects` / `~/.codex/` carry no Hermes profile (`profile: None`), so once the active profile was a named (non-root) one, the detail-load profile gate 404'd every one of them — the row showed in the sidebar and then rendered "Session not available in web UI." when clicked. These profile-less external-agent rows are now exempted from the visibility gate so they open identically on root and named profiles; every state.db-backed row (CLI/messaging/cron) that *does* carry a profile stays fully scoped. Thanks @kk17. (#6690)
|
||||
|
||||
- **Workspace artifact links with Windows backslash paths now open.** `openArtifactPath()` / `_workspacePathExists()` split on `/`, so a Windows absolute artifact path like `D:\workspace\dir\file` never matched the workspace-prefix strip and was treated as a single filename — `/api/list` never resolved it and the click reported "cannot open file." Both the path and the session workspace are now normalized to `/` separators before the prefix strip and existence check; POSIX paths are byte-identical to before. Thanks @fatbee0808. (#6763)
|
||||
|
||||
- **The chat page no longer risks a `ReferenceError` before settings finish loading.** `static/ui.js` read the `_autoScrollFollow` auto-scroll setting as a bare global at several scroll/settlement call sites, but `boot.js` only assigns `window._autoScrollFollow` after the settings request resolves — so a scroll or DOM-replace path firing during that window could throw `ReferenceError: _autoScrollFollow is not defined` and break the golden-path render. The setting is now initialized synchronously (default on) before any reader runs, and every reader goes through the explicit `window._autoScrollFollow` owner; a user's saved `false` still survives hydration and explicit bottom settlement is never suppressed. Thanks @webtecnica. (#6614, #6606)
|
||||
|
||||
- **Docker containers start again against recent hermes-agent builds.** `docker_init.bash` installed hermes-agent with a normal `uv pip install`, which triggers `bdist_wheel` — now rejected by hermes-agent's Nix-only build guard, so the container exited before reaching `server.py`. The init script now uses the supported editable install (`uv pip install -e`) from a staged source kept under the persistent `/app/hermes-agent-src` (same lifecycle as the venv and the `.deps_installed` marker), so the PEP 660 editable link never outlives its target across restarts. The source-absent reduced-functionality fast-restart path is preserved. Thanks @qxxaa. (#6458, #6441)
|
||||
|
||||
- **The live Worklog no longer flashes a blank frame before the final answer settles.** On normal turn completion the live tool/thinking rows were cleared *before* the canonical settled transcript rendered, so on a costly rebuild readers could see a blank or intermediate frame between the live Worklog and the final answer. The live DOM under the assistant turn is now preserved until the settled renderer replaces it (a new `clearLiveToolCards({preserveDom:true})` path used only by the normal `done` settlement — cancel, error, and replace paths still clear unconditionally), and the scroll-snapshot is captured from the intact live DOM so a reader scrolled up mid-Worklog keeps their position through settlement. The one-shot forced-open/collapse protection is unchanged. Thanks @franksong2702. (#6473, #6472)
|
||||
|
||||
- **Auto-generated session titles no longer stick as a trivial echo like "pong" or "Done".** Title generation now distinguishes a *freshly generated* candidate from an *already-stored* title: a new candidate that is just a one-word echo reply (`pong`, `ping`, `yes`, `done`, `cool`, `thanks`, …) or a completion filler (`ok`, `all set`, `finished`, …) is rejected so the title falls back to a meaningful local summary instead. At the same time, those same short words remain *valid* as an existing stored title, so a session that legitimately settled on a short title no longer re-fires the title LLM on every subsequent turn. Substantive agentic first-turn plans are still eligible as title sources. Thanks @cabluvsmkm2009-source. (#6529)
|
||||
|
||||
- **Live SSE relays no longer hang after an application error.** The relay drain loops for `/api/chat/stream` and the run-event stream only closed on the hardcoded set `("stream_end", "error", "cancel")`, but the live chat path emits `apperror` as a terminal event with **no** trailing `stream_end` (it returns immediately after `put('apperror', …)`), and a bare `error` event is never emitted on that path — so a turn that ended in an application error left the client's EventSource loop draining forever. The three relay loops (`_stream_runner_run_events`, `_handle_sse_stream`, `emit_session_snapshot`) now share a single `api.run_journal.SSE_RELAY_CLOSE_EVENTS` close set (`stream_end`, `cancel`, `apperror`, and the still-real runner-adapter `error`); `done` is intentionally excluded because `title` and `stream_end` legitimately follow it. The post-cancel event-drop guard is corrected to keep delivering `apperror` (not the phantom `error`). Thanks @cabluvsmkm2009-source. (#6527)
|
||||
|
||||
- **The admin model-reload endpoint (`POST /api/admin/reload`) no longer crashes.** Reloading `api.models` rebound three names onto the routes module, but one of them — `_routes.compact = _models.compact` — referenced `compact` as if it were a module-level attribute when it is actually a method of the `Session` class, so the handler raised `AttributeError: module 'api.models' has no attribute 'compact'` and the reload failed with a 500. The invalid rebinding is removed (nothing reads `api.routes.compact`); the two genuinely module-level rebindings (`get_session`, `Session`) are unchanged, and the endpoint now returns `{"status": "ok"}`. Thanks @mspgle-dotcom. (#6477)
|
||||
|
||||
- **Typing in the composer no longer causes a layout round-trip on every keystroke.** A single-row composer append now skips rewriting the textarea height when the height can't change, removing a per-keystroke reflow that could disturb caret stability and assistive-tech focus. An oversized composer, a text replacement, and session/draft restore still fully remeasure so the box shrinks back correctly, and the minimum-height check parses only genuine pixel values (a percentage/`auto`/`calc()` min-height fails closed to the full resize). Thanks @sheldon-im. (#6349)
|
||||
|
||||
- **Gateway approval cards are now routed by run ID, so approvals and Stop always act on the right run (#6008).** A gateway approval card that arrived without an `approval_id` couldn't be acted on, and with multiple concurrent runs an approval or a Stop could be applied to the wrong run. Approvals are now owned per-`(session, run)`: identity-less and identity-bearing approvals route through the same single-flight owner, concurrent duplicate submits can't double-relay, different runs don't block each other, and a failed relay releases ownership so a retry can proceed. The composer/sidebar Stop now waits boundedly for an acknowledged exact-run gateway stop and surfaces a persistent, actionable "Cancel failed." error toast (with Copy and Dismiss, localized) instead of silently settling the UI when the stop fails. When multiple approvals are queued the card shows a "1 of N pending" counter. Thanks @rodboev. (#6040, #6008)
|
||||
@@ -67,12 +363,8 @@
|
||||
|
||||
- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291)
|
||||
|
||||
- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400)
|
||||
|
||||
- **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306)
|
||||
|
||||
- **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400)
|
||||
|
||||
- **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083)
|
||||
|
||||
- **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836)
|
||||
@@ -95,56 +387,6 @@
|
||||
|
||||
- **Async delegation (`delegate_task`) results now wake the exact browser tab that started them, exactly once, even across a WebUI restart.** Two problems on the async-completion delivery path are fixed together. (1) *Misroute:* a detached completion was routed by a mutable session-key index, so a result could wake the wrong session; it now routes by the immutable `origin_ui_session_id` return address captured from the commissioning turn, which is authoritative over the index on both delivery paths (the background wakeup and the next-turn drain). (2) *Duplicate on restart:* the completion is now delivered through a durable claim/complete/release lifecycle and acknowledged only after the wakeup turn is accepted, so the restart-restore sweep no longer re-enqueues an already-delivered result. Every failure path (formatter, routing, turn-start, ACK) releases the claim and keeps the result retryable rather than dropping it, and an unrouteable async event is handed to a bounded retry instead of being silently stranded. Thanks @carlotestor (#6185) and @sysophelper-droid (#6159). (#6185, #6159, #6002, #6225)
|
||||
|
||||
- **The Artifacts tab now shows each artifact's file name first, with the directory path secondary.** Artifact rows previously rendered the full path on one line, so the file name (the part you scan for) was truncated at the end of a long path. The name now renders prominently with the directory shown smaller and muted beneath it. Thanks @webtecnica. (#6161)
|
||||
|
||||
- **A session row's streaming indicator now reflects that session's own activity, not a child session's.** The sidebar "streaming" state was derived from a combined flag that was also true when a *child* (subagent) session was streaming, so a row could show as busy on its child's behalf. It now keys off the session's own streaming state. Thanks @webtecnica. (#6165)
|
||||
|
||||
- **`prefers-reduced-motion` now also suppresses the message-row color transition.** Message rows were missing from the reduced-motion transition-disable rule, so a color change (e.g. on theme or state change) still animated for users who asked for reduced motion. `.msg-row` is now included. Thanks @webtecnica. (#6166)
|
||||
|
||||
- **Deliberately picking a model whose bare id also exists under the default provider no longer reverts to the default on re-render.** When a model id (e.g. `glm-5.2`) is offered by both the default provider and another provider, and the provider hint was momentarily unavailable during a dropdown rebuild, the picker's normalized-match fallback snapped to the first (default) group — silently reverting a deliberate non-default pick. The resolver now returns no match for an ambiguous bare id with no provider hint, so the caller injects the correctly provider-scoped option instead of asserting the wrong provider; hinted, uniquely-named, and exact-match lookups are unchanged. Thanks @webtecnica. (#6199, #6195)
|
||||
|
||||
- **Model-emitted base64 images now render as images instead of dumping raw base64 text.** A `data:image/*` URI in an assistant message — whether as ``, a raw `<img src="data:...">`, or a `MEDIA:data:...` reference — previously rendered as a wall of base64 text (or was swallowed entirely), and `` produced a stray `!<a>` anchor. Data-image URIs now render inline under a strict allowlist (raster formats plus base64-only SVG, a safe payload charset, and a 2 MB cap), and `` routes through the media pipeline. Every other `data:` scheme (`data:text/html`, etc.) stays inert text and is never embedded; the sanitizer permits `data:` for `<img>` only, and the streaming renderer enforces the same predicate so an image can't render mid-stream then vanish at settle. Base64 SVG renders via `<img src>` (scripts can't execute in that context). Thanks @ai-ag2026. (#6209)
|
||||
|
||||
- **A single configured model no longer appears multiple times in the model picker.** A configured model was registered under its bare id, `provider/model`, AND `@provider:model` forms, so the picker treated equivalent routing ids as separate entries and showed the same model more than once. Dedup now collapses only genuinely-equivalent entries (same normalized id **and** same provider), so two different providers offering the same bare model id (e.g. both offer `gpt-4o`) stay distinct while a truly-one-model-registered-three-ways case collapses to one badge — applied to both the live and static catalog builders. A picked provider badge resolves, sends, and persists as that provider (no snap-back to a first-match provider on a bare-id collision), and a colon-bearing model id (e.g. `model-a:free`) synthesized as a missing-catalog fallback reads its authoritative `data-provider` rather than mis-parsing at the last colon. Thanks @happy5318. (#6221, #3691)
|
||||
|
||||
- **Reopening a WebUI tab after a large active session is fast again, even when the session has few messages but large tool outputs.** The reconnect display-path tail optimization only fired based on message *count*, so a session with a handful of messages but multi-MB tool-call JSON forced a slow full-scan merge (bootstrap could take tens of seconds). The optimization now also fires when the session sidecar file exceeds 500 KB, regardless of message count — the same messages are returned (verified: identical message set, count, offset, and truncation metadata), just via the fast path. Thanks @webtecnica. (#6260)
|
||||
|
||||
- **OIDC allowlist values containing spaces (e.g. a group named `Hermes Users`) are no longer split into separate entries.** The allowlist parser split comma-separated values on all whitespace, so a multi-word group/user name became two spurious entries. Allowlist parsing now splits only on commas/newlines (multi-word values stay intact and match their exact signed claim — strictly tightening, no security loosening), while OAuth **scope** parsing keeps its space-delimited behavior per RFC 6749 §3.3. A blank allowlist entry no longer bricks an OIDC-only deployment. Thanks @webtecnica. (#6244)
|
||||
|
||||
- **`/sessions` and `/resume` typed in the composer now open the session browser instead of being sent as chat text.** Both commands appeared in the slash-command autocomplete (they're exposed by `/api/commands`) but the WebUI send-time dispatch didn't handle them, so they were posted to the agent as plain text. They now open the native WebUI session browser and clear the composer — on desktop and (via the mobile-aware opener) on phone-width layouts. Thanks @webtecnica. (#6245, #6224)
|
||||
|
||||
- **Z.AI GLM models now advertise the correct reasoning controls per version.** The WebUI offered the full `reasoning_effort` ladder (max/xhigh/high/medium/low/minimal) for every GLM model, but per Z.AI's API only GLM-5.2+ supports the effort ladder — GLM-4.5 through 5.1 support the binary `thinking` on/off toggle, and GLM-4.7 forces thinking on (not configurable). Reasoning controls are now gated correctly: GLM-5.2+ keeps the full ladder + None, GLM-4.5/4.6/5.0/5.1 get a two-way On/None thinking toggle (no effort levels), and GLM-4.7 shows forced thinking with no toggle. All three surfaces (config status, effort resolution, composer chip) agree across model tiers, provider aliases, and namespaces. Thanks @rh-id. (#6219)
|
||||
|
||||
- **Folder downloads now work when the WebUI is served under a subpath.** The file-tree "Download" context-menu action built an absolute `/api/folder/download?...` URL, which broke behind a reverse proxy that mounts the app under a base path (e.g. `/hermes/`). It now resolves the download URL against `document.baseURI` (matching the pattern the other subpath-aware download paths already use), so it works under any base path and is unchanged on root deployments. Thanks @steezypunk. (#6227)
|
||||
|
||||
- **Reloading an active turn now prefers the durable run-journal over a stale browser cache, and never truncates recovered tool arguments.** On a hard reload/recovery of a live turn, `loadSession()` could keep a stale local cache as the Worklog authority over the server's newer run-journal snapshot — or, worse, mistake a legacy cache entry with no stream identity for the current stream and suppress replay of a newer run's events. Recovery now selects by stream identity first, then run-journal sequence (`_selectLiveRecoveryInflight`), and carries a run-aware `after_event_id` cursor through the whole reconnect pipeline (server-snapshot hydration, in-memory INFLIGHT, localStorage compaction, session-switch persistence, SSE reconnect), resetting the cursor on stream-ownership change. The durable snapshot also preserves full tool-call arguments (previously truncated to 120 chars) so a recovered command/input is never silently shortened, while a shared content-aware bound (`bound_run_journal_snapshot_args`) caps pathological payloads so the snapshot can't balloon. Fixes #6196. Thanks @franksong2702. (#6197, #6196)
|
||||
|
||||
- **The "Compressing context" card no longer flashes on turns that aren't actually compressing.** The card was driven by a broad status match (anything containing `compressing`), so skip/cooldown/defer notices and post-compression summaries (`Compressed X → Y`) could paint a phantom compression card mid-turn. A dedicated `_is_agent_compression_start_status()` now recognizes only the five genuine start-of-compression markers the Hermes agent emits, and the transcript renderer no longer lets an unrecognized `terminal_status` row paint a compression anchor. Real compression turns still show the card unchanged; verified against every current agent compression emitter. Thanks @carlotestor. (#6184)
|
||||
|
||||
- **Transparent Stream no longer shows the start of the final answer twice after a multi-segment turn settles.** In a turn with interim assistant prose interleaved with tool calls, the beginning of the final answer could render twice after the live→settled transition — once as a stale live-token accumulator snapshot, once as the real assistant segment — persisting until reload. The earlier suppression gate (#5758) computed its "after the last tool row" boundary over the combined row list, where settled per-message tool rows re-list tools that ran earlier in the turn, pushing the boundary past the final segment's accumulator so the dedupe never fired. Final-segment eligibility is now judged against the live projection's own chronology (a live-prose row belongs to the final segment only when no *projected* tool row follows it), so the stale prefix is dropped while pre-tool narration and every tool/thinking row are preserved. Thanks @ai-ag2026. (#6189, #5749)
|
||||
|
||||
- **The frontend now reads the `msg_limit` ceiling from `/api/session` metadata instead of hand-mirroring the constant.** Following the ceiling clamp shipped in exp-v0.52.98, the backend advertises its `_MAX_MSG_LIMIT` as `_msg_limit_max` in every `/api/session` response, and the frontend reads it dynamically (`_msgLimitMax`, a module-scope value defaulting to the static fallback), so the two can no longer drift and the mirrored constant's drift-guard test is no longer needed. Older backends that omit the field fall back to the built-in default, so mixed-version deployments are unaffected. Thanks @webtecnica. (#6214, #6177)
|
||||
|
||||
- **`GET /api/session?msg_limit=` is now bounded by a server-side ceiling, and the frontend paginates around it without dropping rows.** A client could request `msg_limit=1000000` (or an outline-jump path asked for `9999`) and force the server to assemble and serialize an unbounded message payload. The backend now clamps `?msg_limit=` to `[1, 500]` via a dedicated `_parse_msg_limit()` helper and sets the existing `_messages_truncated` signal when it clamps; the bare no-`msg_limit` path (branch/undo/jump-to-start) still returns the full transcript. The frontend mirrors the ceiling: `_loadOlderMessages` grows its tail window below the ceiling and switches to bounded `msg_before` backward paging once the server would clamp, and the outline jump uses the bare full-transcript path instead of the old `msg_limit=9999` hack. Two silent row-loss regressions found during review were fixed before ship: a raw-row-heavy `msg_before` page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (losing older rows), and a same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 — both now route through the correct paging/full-transcript path. Thanks @rh-id. (#6152, #6154, #6177)
|
||||
|
||||
- **Background subagents no longer fail to persist when the parent's cached agent is reused mid-turn.** A parent session's `SessionDB` handle is shared by reference with the background subagents it spawns (`delegate_tool`), but the cached-agent reuse path unconditionally closed that handle when the parent got a new turn / server-side wakeup — so every still-running child then failed `append_message` with `'NoneType' object has no attribute 'execute'` (observed 600+ times under one parent). The reuse path now keeps a still-open handle (closing only the unused fresh one, preserving the #1421 FD-leak fix) and only replaces a dead/missing one. The credential self-heal path was hardened the same way: if rebuilding the handle fails it degrades cleanly to a lazy reinit instead of reusing a closed handle. Thanks @carlotestor. (#6143)
|
||||
|
||||
- **The CLI/cron session-list projection cache is now bounded.** `_CLI_SESSIONS_CACHE` was an unbounded plain dict of deep-copied session-list projections whose fingerprint-folded key advances on every streamed message, so orphaned heavy copies accumulated on a long-lived server until the next structural clear. It's now an `OrderedDict` capped at 8 entries with drop-oldest LRU eviction (recency refresh on hit), mirroring the existing `_CLAUDE_CODE_PARSE_CACHE` / `_SIDECAR_METADATA_CACHE` pattern; TTL stays the freshness control. Thanks @rh-id. (#6140)
|
||||
|
||||
- **Models selected from a list-shaped custom-provider allowlist now route to the right provider.** A `custom_providers[].models` allowlist can be written as either a mapping or a list, and the model picker accepted both — but runtime routing only read mapping keys, so a model chosen from a *list*-shaped allowlist could silently stay on the active/default provider instead of its own. The supported-model-ID extraction is now centralized in one helper used by both catalog construction and routing, so the picker and runtime can no longer drift. Existing mapping-shaped and list-of-strings configs are unaffected. Thanks @franksong2702. (#6127, #6121)
|
||||
|
||||
- **Stale sidebar approval attention badge now clears once its gateway approval has drained.** A parent session could keep showing the red approval "attention" dot in the sidebar after the underlying gateway approval was already resolved/gone — the live gateway queue drained but the sidebar summary kept the mirrored approval alive. `_session_attention_summary()` now reconciles the gateway pending-mirror before counting attention (the same self-healing repair the approval-pending route already does), so the badge clears correctly. Live approvals stay visible and actionable and the clarify fallback is unchanged. This is the WebUI read-path mitigation; the child-session context-propagation root cause stays tracked in #6100. Thanks @rodboev. (#6117, #6100)
|
||||
|
||||
- **The mobile composer model picker no longer opens off-screen.** On phones the model dropdown could open below the fold (looking like it "won't open", issue #6080) because it lived inside `.composer-footer`, whose `container-type` (and, under the Geist Contrast skin, `backdrop-filter`) made the footer the containing block for the dropdown's fixed positioning — so it anchored to the bottom-of-screen footer instead of the viewport. The picker now reparents to `<body>` while open (the same idiom the profile dropdown already uses), so its fixed positioning resolves against the visual viewport on every skin; it flips above/below the anchor by available space, clamps below the titlebar, and re-positions as the on-screen keyboard / URL bar move the visual viewport. Desktop behavior is unchanged. Thanks @webtecnica. (#6105, #6080)
|
||||
|
||||
- **Approval and clarify popups no longer collapse to tiny blank windows on very narrow screens.** On very narrow viewports the clarify card could shrink to an unreadable sliver; it now gets a `min-width` and `overflow:visible` matching the approval card. Thanks @webtecnica. (#6104, #6094)
|
||||
|
||||
- **Stream event timestamps no longer break the row layout on narrow foldable screens.** On screens under 360px the per-event timestamps could spill out of tool/thinking card headers; the headers now clip overflow so the timestamp layout holds. (Tool names stay visible — an earlier draft hid them at ≤360px, which lost tool identity; the header clip alone prevents the spill.) Thanks @webtecnica. (#6106, #6099)
|
||||
|
||||
- **Streaming sidebar polls no longer re-run the expensive CLI/cron projection every few seconds.** The per-stream cache TTL was pinned at 30s while the frontend's streaming sidebar poll cadence could approach it, so the state.db CLI/cron projection (and its ~200 sidecar reads) could be re-paid on nearly every poll during a live turn (the #4842/#4808 high-CPU `get_cli_sessions` case). The streaming TTL is now held strictly above the sidebar poll interval (45s) and paired with the stable streaming cache key, so repeated polls reuse the projection; a new test asserts the TTL stays above the poll cadence so this can't silently regress. Thanks @starship-s. (#6102, #4842)
|
||||
|
||||
- **Delegated subagent sessions in the sidebar now show a meaningful title instead of "Subagent Session".** Generic delegated child sessions previously all displayed the placeholder "Subagent Session" in the conversations list, making them indistinguishable. Their sidebar title is now derived (view-only) from the child's first user message during the existing batched sidebar-metadata pass — without mutating the stored session title, so the read-only boundary is preserved. Only generic "Subagent Session" placeholders are affected; custom titles are left untouched. Thanks @rodboev. (#6056, #6033)
|
||||
|
||||
- **Concurrent remote-gateway health checks no longer stampede the gateway.** When many requests needed a remote-gateway health probe at once, each fired its own probe (a thundering herd). Probes are now single-flighted: one "leader" thread probes while latecomers wait (bounded) and share the result, guarded by a single condition/lock with a `finally` that always releases waiters even if the probe errors. Thanks @ai-ag2026. (#5798, #5455)
|
||||
|
||||
- **Appending run-journal events no longer gets slower as a session's journal grows.** The next sequence number was recomputed by scanning existing entries on every append (O(n²) over a session's lifetime); it's now cached per journal path (guarded by a dedicated lock, evicted on journal delete), making each append O(1). Thanks @ai-ag2026. (#5799)
|
||||
@@ -365,13 +607,53 @@
|
||||
|
||||
- **No more 57–70 s cold-startup stalls from the profile skills-stats thundering herd.** At container boot the frontend fires several profile-data requests at once; with `ThreadingHTTPServer` (one thread per request) they all missed the empty skills-stats cache simultaneously and each walked + parsed every profile's skill tree, stacking thousands of concurrent `stat()` calls under Docker's overlay2 filesystem. `_get_profile_skills_stats()` now serializes per-profile with double-checked locking (concurrent misses on one profile collapse to a single compute; independent profiles still compute in parallel), and `list_profiles_api()` single-flights the row build under `_LIST_PROFILES_CACHE_LOCK` so one thread builds while the rest wait for the cached result. The every-call cheap mtime probe (the #4783 out-of-band change-detection contract) is unchanged. (#5364)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The System/health panel now reports WebUI runtime memory diagnostics.** Beyond host CPU/RAM/disk, the system-health response now observes the process-owned caches and stream structures named in #6351 (session-list cache, agent cache, live-stream structures) by reading their existing owners non-blockingly — a collector that never waits on an owner lock, so it can't stall a request. This surfaces sizes/bounds that were previously invisible for diagnosing runaway memory. Thanks @rodboev. (#6363)
|
||||
|
||||
- **Slash-command autocomplete now searches skills by keyword.** Typing `/` and a term now matches plain skills on a case-insensitive keyword in their **name or description** (previously only a name prefix), so a skill is discoverable by what it does, not just its exact slug. Built-in, agent/plugin, and bundle commands keep prefix matching and always take precedence over a same-slug skill, and skill suggestions are held until the bundle-metadata request settles so a slow response can't briefly surface a shadowed skill. Thanks @MinhoJJang. (#6932)
|
||||
|
||||
- **Extensions can observe turn lifecycle events via `handle.events.on(...)`.** A registered extension (see `window.hermesExt.register`) can now subscribe to session turn-lifecycle events — `turn:start`, `turn:complete`, `turn:error`, and `turn:cancel` — through a frozen `events` accessor on its handle, each delivering a frozen scalar event (`sessionId`, `streamId`, `status`, and start/end timestamps; no message content). Core owns the authoritative live-session stream and emits each event exactly once per real transition (a per-session/stream state machine suppresses duplicate starts and post-terminal events, including on reconnect/replay), with per-listener error isolation so a misbehaving extension cannot break streaming. Subscriptions return an idempotent unsubscribe. This is the B1 observer surface on top of the E0 identity handle — a stable page-local facade instead of extensions polling private DOM state. Thanks @franksong2702. (#6924)
|
||||
|
||||
- **Extensions can claim a cooperative page identity via `window.hermesExt.register(id)`.** An injected extension can now claim a stable browser-page identity together with its existing settings and storage accessors: `register(id)` returns a frozen `{id, settings, storage}` handle for an ID present in the effective-enabled, Core-sanitized manifest inventory captured at page boot, and returns `null` (fail-closed, no namespace side effects) for empty, malformed, unknown, or untrusted IDs. Re-registering the same ID in one page returns the same handle; boot-time trust is immutable across status refreshes (a refresh cannot elevate an ID absent at boot). This is cooperative attribution for extensions sharing the page — explicitly not a sandbox, capability, permission, isolation mechanism, or security boundary. Thanks @franksong2702. (#6795)
|
||||
|
||||
- **Refine selected transcript text directly in the composer.** Selecting text inside a chat message now shows a "Refine" action alongside "Reply with selection" in the floating selection toolbar. Refine seeds the selected text into the composer as an editable draft (a quoted block plus a localized "Refine instruction:" line, caret ready) so you can shape a follow-up before sending — the ChatGPT/Notion-style quote-to-edit flow. The transfer is one-way and marker-free (no internal sentinel reaches the composer or the sent message), preserves any existing draft and attachments, collapses to 44×44 icon-only buttons on phones, and covers all 15 locales. Thanks @rodboev. (#6410)
|
||||
|
||||
- **Agent replay is now byte-accurate without ever exposing internal provider text to the browser.** The exact provider-facing reply text (`api_content`) is preserved through the internal session store so agent-to-agent replay is faithful, while every client-facing surface (HTTP responses, SSE journal + runner streams, shares) strips it and its provenance aliases through the public projection. Includes a maintainer hardening pass: session redaction is selective (transcript fields are scrubbed; operational fields such as the workspace path pass through unchanged, so a workspace path that merely contains credential-shaped text is no longer masked into an invalid path), and the runner-backed SSE relay now projects payloads before emit so `api_content` cannot leak through that path. Thanks @franksong2702. (#6757)
|
||||
|
||||
- **Transparent Stream copy buttons now confirm success in place.** The tool-event and thinking copy controls flash a check for ~1.5s after a successful copy — the same feedback the normal chat copy buttons already give — then revert to the copy glyph. The confirmation is now clearly visible without hover (important on touch), stays correct across live-row reconciliation, cleans up if its row is torn down mid-feedback, and is never serialized into a restored session snapshot. Thanks @Stacey2911. (#6037)
|
||||
|
||||
- **Kanban boards can now set a default workspace path.** The board settings modal gains a "Default workspace path" field (a type-or-pick combobox sourced from your saved workspaces, so you can choose a known path or type a custom one — validated server-side). Tasks on the board without an explicit workspace path inherit this default. The board switcher chip is now always shown (even with a single board) and its menu item is now "Board settings…", enabled for the default board. Thanks @rodboev. (#6055, #5932)
|
||||
|
||||
- **The workspace file tree can now be sorted, and the "Show hidden files" toggle moved into a menu.** The workspace pane's ⋮ options menu gains a "Sort by" group — Name (A→Z), Name (Z→A), Date created, Date modified — and your choice persists across sessions. "Date created" is disabled with an inline note on platforms/servers that don't report creation time. The pre-existing "Show hidden files" toggle moves from a permanent inline row into that same menu, reclaiming vertical space on every workspace view; a small indicator on the heading still reflects when hidden files are shown. Thanks @rodboev. (#6091, #6066)
|
||||
|
||||
- **Kanban worker sessions can now be hidden from the default sidebar.** A new "Show kanban sessions" toggle (nested under "Show non-WebUI sessions", default off) keeps automated kanban worker runs — which can flood the list — out of the default sidebar, extending the same background-session hide already used for cron and webhook sessions. Turning it on shows them; an explicit kanban source filter always reveals them regardless of the toggle, and a project-assigned kanban session still surfaces under its project chip. Thanks @vduke. (#6780)
|
||||
|
||||
- **Typography is now driven by three explicit font tokens (`--font-ui`, `--font-conversation`, `--font-mono`), so a theme can style conversation prose separately from UI chrome.** The shell chrome/controls use `--font-ui`, message prose uses `--font-conversation` (which defaults to `--font-ui`, so there is no change to the default appearance — assistant/user prose stays on the system sans, not a serif), and code/logs/terminal use `--font-mono`. The embedded terminal now also re-fits its glyph metrics after web fonts finish loading (guarded against double-fit and disposed terminals) so its layout stays correct. This is groundwork that lets skins opt into a distinct reading font without changing the default for everyone. Thanks @starship-s. (#5918)
|
||||
|
||||
- **Active in-flight recovery snapshots are now sent in a compact transport form.** When you reload or reattach to a session with a run in progress, the `/api/session` payload no longer carries multiple redundant copies of each tool result and row value — it projects the live run-journal snapshot into a smaller, display-equivalent form (one authoritative source per value) while preserving the live message's timestamp so the reconstructed turn keeps stable identity. The durable journal and the canonical in-process snapshot are unchanged, so recovery renders identically with less payload. Thanks @allenliang2022. (#6858)
|
||||
|
||||
- **Background-process completions and watch matches now render as compact, collapsible summary cards.** Routine wakeups no longer fill the transcript with an always-expanded agent-facing prompt: the collapsed row shows the command, exit status or matched watch pattern, and timestamp, while one click reveals the complete command and verbatim output. Failed processes keep a red accent, long watch patterns remain reachable on touch and keyboard when expanded, and unfamiliar future event shapes safely retain the existing raw-notice fallback. Thanks @b3nw. (#6350, #6345)
|
||||
|
||||
- **The repository is now pip-installable with standard packaging metadata.** `pyproject.toml` gains a `[build-system]` + `[project]` section (setuptools + setuptools-scm) so packagers and distros can `pip install .` and get a proper wheel (`api` + `static` bundled). The normal `bootstrap.py` / `start.sh` / `ctl.sh` source-checkout launch path is unchanged. setuptools-scm writes its version to a separate `api/_scm_version.py` (not the Docker-owned `api/_version.py`), and the runtime version detector reads it as an explicit fallback (normalizing the PEP 440 value to a `v…` form) so an installed wheel reports a real version instead of `unknown` — Docker and git-checkout version resolution are byte-identical to before. Thanks @rodboev. (#6337, #2695)
|
||||
|
||||
- **Internal: auxiliary-title test suites no longer leak a synthetic `agent` module across the process.** `test_title_aux_routing.py` and `test_2235_initial_aux_title.py` installed stub `agent` / `agent.auxiliary_client` modules via `sys.modules.setdefault` at import time and never removed them, so any later test importing `agent.context_compressor` in the same process got the stub and failed (breaking `test_issue4685_post_compression_context_metering.py` by run order). Both suites now route the install through a shared `tests/_aux_client_helpers.py` chokepoint scoped with `unittest.mock.patch.dict`, plus four tests covering restore-by-identity, absent-key cleanup, present-but-`None` preservation, and restore-on-raise. Test-only, no production change. Thanks @rodboev. (#6661, #6630)
|
||||
|
||||
- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155)
|
||||
|
||||
- **Internal: added regression coverage for the empty-`tool_calls` drop on the metadata-restoration mirror path.** `_api_safe_message_positions()` must apply the same `tool_calls: []` drop as the main API sanitizer (#5737) so metadata restoration never treats a `tool_calls: []` row as a distinct message; a new test pins that empty arrays lose the key while populated tool-call chains (and their tool results) survive untouched. Test-only, no production change. Thanks @webtecnica. (#6803, #6796)
|
||||
|
||||
- **Internal: added a two-direction regression for identical-prompt retry settlement.** The active-turn ownership repair that resolves #6571 (settlement materializes the exact pending turn before checking for a terminal assistant answer) had no issue-specific coverage because the original reproduction omitted the runtime turn identity. A new test pins both directions through the real `_merged_transcript_lacks_final_assistant_answer()`: a successful identical-prompt retry keeps its terminal answer (not a `no_response`), while an otherwise-identical retry with no terminal answer still qualifies as a genuine failure. Test-only, no production change (a mutation bite — nulling the merge's `active_turn_identity` provenance — flips the successful case red, confirming the guard is live). (#6631, #6571)
|
||||
|
||||
- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145)
|
||||
|
||||
### Added
|
||||
|
||||
- **Extension API: session-open interception + transcript rendering.** Two new extension-facing hooks let extensions react to session navigation and render message transcripts without DOM hacking. `window.registerHermesSessionOpenHandler(fn)` fires when a session opens — with `{preload:true}` a handler may return `{cancel:true}` to abort the navigation cleanly (no side-effect: no sidebar close, profile switch, stream open, transcript clear, or URL change on any entry path), and with `{loaded:true}` it runs after the session is fully populated. `window.renderTranscript(container, messages, opts)` renders an array of messages into any container, routing content through the same sanitized `renderMd`/`SAFE_TAGS` pipeline the chat uses (with a `textContent` fallback), so it carries no new XSS surface. Follows the established `registerHermesTtsEngine` registration pattern. Thanks @ChonSong. (#5508)
|
||||
- **GLM-5.3 Flash (`zai/glm-5.3-flash`) is now selectable in the Z.AI model list.** Z.ai's fast/cheap native-multimodal sibling of GLM-5.3 is added to the direct Z.AI catalog and the provider fallback list, following the same base-then-flash convention as the other GLM entries and inheriting the ≥5.2 reasoning-effort ladder. The onboarding default deliberately stays GLM-5.1. Thanks @rh-id. (#7323)
|
||||
|
||||
- **Pinch-to-zoom for Mermaid diagrams on touch devices.** The zoomable Mermaid viewer (which already had mouse-wheel zoom + single-finger pan) now supports two-finger pinch-to-zoom on phones/tablets, anchored on the pinch midpoint. Pointer/drag handlers are guarded so an active pinch never fights the pan gesture; desktop mouse-wheel zoom and single-finger pan are unchanged. Uses Touch Events for iOS Safari robustness. Thanks @silent-reader-cn. (#5913)
|
||||
- **Extensions can register a native "Configure" entry point under Settings → Extensions → Installed.** For complex editors that don't fit scalar `settings_schema` fields, a boot-trusted extension can call `ext.settings.registerConfigure(handler)` on the E0 handle to add one native Configure button for its current effective-enabled Installed row, while keeping the editor UI, validation, state, and persistence fully extension-owned. Registration is trust-gated and quarantine-aware, the first handler per extension wins (duplicates return `null`), and it returns an idempotent unregister; the button never appears in Diagnostics and is removed immediately on disable/uninstall. Thanks @franksong2702. (#7111)
|
||||
|
||||
- **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062)
|
||||
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
|
||||
|
||||
### Performance
|
||||
|
||||
@@ -403,6 +685,10 @@
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Remote access docs now lead with Tailscale Serve**, which keeps the WebUI bound to loopback while giving your tailnet an HTTPS MagicDNS hostname, including the Linux `serve config denied` permission fix (`tailscale set --operator=$USER`). Binding to `0.0.0.0` on the tailnet is documented as the fallback, with password auth called out as required. Thanks @evgenyponomarev. (#7430, closes #7419)
|
||||
|
||||
- The conversation-lifecycle regression gate test now records the proposal → implementation link in its header. Thanks @webtecnica. (#6330)
|
||||
|
||||
- **Documented the reverse-proxy basic-auth caveat for installed PWAs.** README, onboarding, and troubleshooting now explain that proxy basic auth can block the same-origin service-worker/shell update fetches an installed PWA needs (leaving it on a blank screen after an update), and give recovery steps — prefer WebUI's built-in password, or scope proxy auth so `sw.js`/manifest/shell requests complete. Thanks @rodboev. (#5673, #2781)
|
||||
|
||||
- **Documented the `HERMES_WEBUI_*` environment variables and refreshed stale version/test/locale references.** README, ARCHITECTURE, TESTING, ROADMAP, SPRINTS, and the `.env` example files now describe the supported runtime env vars (host/port/state-dir/agent-dir/home) and no longer carry stale hardcoded version/test-count/locale figures. Thanks @mo7al876any. (#5536)
|
||||
@@ -411,6 +697,8 @@
|
||||
|
||||
### Internal
|
||||
|
||||
- **Extracted the 585-line inline `GET /api/session` handler out of the giant `handle_get` dispatcher into a dedicated `_handle_session_get` function.** Behavior-preserving refactor: the extracted body is AST-identical to the original inline block, the route condition and its precedence relative to sibling routes (`/api/sessions`, `/api/session/lineage/report`, `/api/projects`) are unchanged, and response projection, redaction, and error handling are byte-for-byte the same. Makes the most-hit session route far easier to read and maintain. Thanks @zicochaos. (#7340)
|
||||
|
||||
- **Hardened `test_tls_support::test_tls_startup_failure_fallback_to_http` against stdout-preamble noise.** The test asserted the server prints "TLS setup failed" by reading only the first 2000 bytes of its stdout. When the test interpreter lacks optional deps (`requests`/`websockets`), startup emits a burst of plugin/tool import warnings plus the startup-config banner *before* the TLS line, pushing the marker past that fixed window → spurious failure (distinct from the agent-package-isolation cause fixed separately below). It now drains all currently-available stdout (bounded by a 5s deadline, short-circuiting once the marker is seen) instead of a single fixed-size read. Verified failing→passing in a fresh venv without the optional deps. Test-only.
|
||||
|
||||
- **Fixed the last 2 chronic local full-suite test-isolation flakes.** `test_tls_support::test_tls_startup_failure_fallback_to_http` and `test_v050259_sessiondb_fd_leak::test_session_db_close_is_idempotent` failed only in a full local suite run (they skip on CI, where the agent package isn't co-located). Root cause was the same "simulate agent package unavailable" antipattern shipped-fixed for the profile cluster: `test_custom_provider_prefix_collisions` clobbered `sys.modules['agent']` at collection time (never restored), and several helpers emptied real package `__path__` lists in place. Fixed at the source — the collection-time shim is now guarded on `importlib.util.find_spec` (only installed when the real package is genuinely absent), the in-place `__path__` mutations use `monkeypatch.setattr(..., raising=False)`, and `test_issue1574`'s fake-agent helper restores the env/`sys.path` it mutates. Full local suite now 0 failed (was 2); `test_title_aux_routing` unaffected. Test-only.
|
||||
@@ -425,6 +713,102 @@
|
||||
|
||||
- **Added regression coverage for messaging clear-watermark semantics.** New test locks the watermark behavior so a future change can't silently regress it. Thanks @rodboev. (#5589, #5572)
|
||||
|
||||
## [v0.52.113] — 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Custom-provider models keep their exact identity across catalog refreshes.** When a session used a model served through a custom provider whose upstream id contains a namespace (e.g. `z-ai/glm-5.2` under a `tokenrouter` custom endpoint), a catalog refresh could mis-resolve the picker selection — matching a *different* model whose bare suffix collided, or (in an interim fix) fabricating a new bare-id option instead of re-selecting the real one. Model resolution now matches the exact routed id first, and only falls back to a provider-hinted bare-id repair (preserving the older #6195 behavior for legacy sessions that stored just the suffix) when there is exactly one unambiguous candidate — otherwise it declines to guess. The resolved value is always an existing dropdown option, so re-renders no longer inject duplicates or lose the selection. Thanks @josephcy95. (#6944)
|
||||
|
||||
- **The Artifacts tab now shows each artifact's file name first, with the directory path secondary.** Artifact rows previously rendered the full path on one line, so the file name (the part you scan for) was truncated at the end of a long path. The name now renders prominently with the directory shown smaller and muted beneath it. Thanks @webtecnica. (#6161)
|
||||
|
||||
- **A session row's streaming indicator now reflects that session's own activity, not a child session's.** The sidebar "streaming" state was derived from a combined flag that was also true when a *child* (subagent) session was streaming, so a row could show as busy on its child's behalf. It now keys off the session's own streaming state. Thanks @webtecnica. (#6165)
|
||||
|
||||
- **`prefers-reduced-motion` now also suppresses the message-row color transition.** Message rows were missing from the reduced-motion transition-disable rule, so a color change (e.g. on theme or state change) still animated for users who asked for reduced motion. `.msg-row` is now included. Thanks @webtecnica. (#6166)
|
||||
|
||||
- **Deliberately picking a model whose bare id also exists under the default provider no longer reverts to the default on re-render.** When a model id (e.g. `glm-5.2`) is offered by both the default provider and another provider, and the provider hint was momentarily unavailable during a dropdown rebuild, the picker's normalized-match fallback snapped to the first (default) group — silently reverting a deliberate non-default pick. The resolver now returns no match for an ambiguous bare id with no provider hint, so the caller injects the correctly provider-scoped option instead of asserting the wrong provider; hinted, uniquely-named, and exact-match lookups are unchanged. Thanks @webtecnica. (#6199, #6195)
|
||||
|
||||
- **Model-emitted base64 images now render as images instead of dumping raw base64 text.** A `data:image/*` URI in an assistant message — whether as ``, a raw `<img src="data:...">`, or a `MEDIA:data:...` reference — previously rendered as a wall of base64 text (or was swallowed entirely), and `` produced a stray `!<a>` anchor. Data-image URIs now render inline under a strict allowlist (raster formats plus base64-only SVG, a safe payload charset, and a 2 MB cap), and `` routes through the media pipeline. Every other `data:` scheme (`data:text/html`, etc.) stays inert text and is never embedded; the sanitizer permits `data:` for `<img>` only, and the streaming renderer enforces the same predicate so an image can't render mid-stream then vanish at settle. Base64 SVG renders via `<img src>` (scripts can't execute in that context). Thanks @ai-ag2026. (#6209)
|
||||
|
||||
- **A single configured model no longer appears multiple times in the model picker.** A configured model was registered under its bare id, `provider/model`, AND `@provider:model` forms, so the picker treated equivalent routing ids as separate entries and showed the same model more than once. Dedup now collapses only genuinely-equivalent entries (same normalized id **and** same provider), so two different providers offering the same bare model id (e.g. both offer `gpt-4o`) stay distinct while a truly-one-model-registered-three-ways case collapses to one badge — applied to both the live and static catalog builders. A picked provider badge resolves, sends, and persists as that provider (no snap-back to a first-match provider on a bare-id collision), and a colon-bearing model id (e.g. `model-a:free`) synthesized as a missing-catalog fallback reads its authoritative `data-provider` rather than mis-parsing at the last colon. Thanks @happy5318. (#6221, #3691)
|
||||
|
||||
- **Reopening a WebUI tab after a large active session is fast again, even when the session has few messages but large tool outputs.** The reconnect display-path tail optimization only fired based on message *count*, so a session with a handful of messages but multi-MB tool-call JSON forced a slow full-scan merge (bootstrap could take tens of seconds). The optimization now also fires when the session sidecar file exceeds 500 KB, regardless of message count — the same messages are returned (verified: identical message set, count, offset, and truncation metadata), just via the fast path. Thanks @webtecnica. (#6260)
|
||||
|
||||
- **OIDC allowlist values containing spaces (e.g. a group named `Hermes Users`) are no longer split into separate entries.** The allowlist parser split comma-separated values on all whitespace, so a multi-word group/user name became two spurious entries. Allowlist parsing now splits only on commas/newlines (multi-word values stay intact and match their exact signed claim — strictly tightening, no security loosening), while OAuth **scope** parsing keeps its space-delimited behavior per RFC 6749 §3.3. A blank allowlist entry no longer bricks an OIDC-only deployment. Thanks @webtecnica. (#6244)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258)
|
||||
|
||||
## [exp-v0.52.157] — 2026-07-29 — Experimental release (reconnect transcript ordering)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **On SSE reconnect / mobile background-resume, the in-flight user prompt now stays above the live assistant worklog instead of jumping below it.** Both recovery paths (`loadSession` reattach and `refreshSession` soft-recovery, the iOS PWA background→resume path) now route the synthesized pending user message through one shared, identity-aware merge helper that inserts it immediately before the live assistant turn it started (and repairs the row in place if a prior render pushed it below). The helper is idempotent — a repeated recovery won't duplicate the prompt — and preserves legitimate repeated user turns. Thanks @nankingjing. (#6419, #6457)
|
||||
|
||||
## [exp-v0.52.156] — 2026-07-29 — Experimental release (hard-refresh session restore)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A hard refresh (or PWA relaunch) no longer loads the wrong chat when a stale `action=new-chat` launch parameter lingers in the URL.** An explicit `/session/<id>` (or `?session=`) target now always wins over a one-shot PWA new-chat launch action, the launch action is fully consumed via `history.replaceState` so Back + reload can't spawn a second empty chat, and only the exact `action=new-chat` pair is stripped from the restored URL (other query params, duplicate action values, and the hash are preserved). Thanks @ruizanthony. (#6507)
|
||||
|
||||
## [v0.52.106] — 2026-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **Transparent Stream can now hide its per-event timestamp chips.** A new opt-in setting (Settings → the Transparent Stream activity area) lets you suppress the small per-event timestamp chips inside Transparent Stream while keeping the assistant response footer time visible — narrowing the visual noise without removing timing information entirely. Default is unchanged (chips shown). Thanks @rodboev. (#6130, #6099)
|
||||
|
||||
- **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144)
|
||||
|
||||
### Added
|
||||
|
||||
- **Extension API: session-open interception + transcript rendering.** Two new extension-facing hooks let extensions react to session navigation and render message transcripts without DOM hacking. `window.registerHermesSessionOpenHandler(fn)` fires when a session opens — with `{preload:true}` a handler may return `{cancel:true}` to abort the navigation cleanly (no side-effect: no sidebar close, profile switch, stream open, transcript clear, or URL change on any entry path), and with `{loaded:true}` it runs after the session is fully populated. `window.renderTranscript(container, messages, opts)` renders an array of messages into any container, routing content through the same sanitized `renderMd`/`SAFE_TAGS` pipeline the chat uses (with a `textContent` fallback), so it carries no new XSS surface. Follows the established `registerHermesTtsEngine` registration pattern. Thanks @ChonSong. (#5508)
|
||||
|
||||
- **Pinch-to-zoom for Mermaid diagrams on touch devices.** The zoomable Mermaid viewer (which already had mouse-wheel zoom + single-finger pan) now supports two-finger pinch-to-zoom on phones/tablets, anchored on the pinch midpoint. Pointer/drag handlers are guarded so an active pinch never fights the pan gesture; desktop mouse-wheel zoom and single-finger pan are unchanged. Uses Touch Events for iOS Safari robustness. Thanks @silent-reader-cn. (#5913)
|
||||
|
||||
- **Collapsed `read_file` tool rows now show the requested line range.** When the agent reads a slice of a file (`read_file` with `offset`/`limit`), the collapsed tool label now appends the range — e.g. `Read hermes_constants.py · L321-480`, or `L42` for an offset-only read — so you can see which part of a large file was read without expanding the row. Full-file reads and any tool call without a trustworthy positive-integer range keep the plain filename label (invalid, non-integer, non-positive, and overflowing ranges all fall back safely). Only `read_file` is affected. Thanks @franksong2702. (#6062)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Abandoned embedded terminals are now reaped instead of leaking a running shell forever (#4633 backend half).** A WebUI terminal whose viewer dropped its output stream without an explicit close (tab close, crash, network drop) previously kept its shell process and pty fd alive indefinitely — there is no `PDEATHSIG`, so nothing tore it down until the terminal cap happened to evict it. A background reaper now closes any terminal that has been unwatched (zero attached output streams) for longer than a 15-minute grace, and sweeps dead-process terminals immediately; the grace deliberately spans a tab refresh or brief network drop so a genuine reconnect keeps the session. The output-stream attach is now atomic against the reaper (a single registry-locked lookup+subscribe), so a viewer can never end up subscribed to a terminal the reaper is tearing down, and a race resolves to exactly one winner — attach keeps the terminal, or the reaper wins and the stream cleanly reports "terminal not running." A related leak is closed too: if writing the SSE response headers fails after attaching (client already gone), the subscription is now always unsubscribed instead of pinning the terminal un-reapable. Thanks @ai-ag2026. (#5844, #4633)
|
||||
|
||||
- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400)
|
||||
|
||||
- **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400)
|
||||
|
||||
- **`/sessions` and `/resume` typed in the composer now open the session browser instead of being sent as chat text.** Both commands appeared in the slash-command autocomplete (they're exposed by `/api/commands`) but the WebUI send-time dispatch didn't handle them, so they were posted to the agent as plain text. They now open the native WebUI session browser and clear the composer — on desktop and (via the mobile-aware opener) on phone-width layouts. Thanks @webtecnica. (#6245, #6224)
|
||||
|
||||
- **Z.AI GLM models now advertise the correct reasoning controls per version.** The WebUI offered the full `reasoning_effort` ladder (max/xhigh/high/medium/low/minimal) for every GLM model, but per Z.AI's API only GLM-5.2+ supports the effort ladder — GLM-4.5 through 5.1 support the binary `thinking` on/off toggle, and GLM-4.7 forces thinking on (not configurable). Reasoning controls are now gated correctly: GLM-5.2+ keeps the full ladder + None, GLM-4.5/4.6/5.0/5.1 get a two-way On/None thinking toggle (no effort levels), and GLM-4.7 shows forced thinking with no toggle. All three surfaces (config status, effort resolution, composer chip) agree across model tiers, provider aliases, and namespaces. Thanks @rh-id. (#6219)
|
||||
|
||||
- **Folder downloads now work when the WebUI is served under a subpath.** The file-tree "Download" context-menu action built an absolute `/api/folder/download?...` URL, which broke behind a reverse proxy that mounts the app under a base path (e.g. `/hermes/`). It now resolves the download URL against `document.baseURI` (matching the pattern the other subpath-aware download paths already use), so it works under any base path and is unchanged on root deployments. Thanks @steezypunk. (#6227)
|
||||
|
||||
- **Reloading an active turn now prefers the durable run-journal over a stale browser cache, and never truncates recovered tool arguments.** On a hard reload/recovery of a live turn, `loadSession()` could keep a stale local cache as the Worklog authority over the server's newer run-journal snapshot — or, worse, mistake a legacy cache entry with no stream identity for the current stream and suppress replay of a newer run's events. Recovery now selects by stream identity first, then run-journal sequence (`_selectLiveRecoveryInflight`), and carries a run-aware `after_event_id` cursor through the whole reconnect pipeline (server-snapshot hydration, in-memory INFLIGHT, localStorage compaction, session-switch persistence, SSE reconnect), resetting the cursor on stream-ownership change. The durable snapshot also preserves full tool-call arguments (previously truncated to 120 chars) so a recovered command/input is never silently shortened, while a shared content-aware bound (`bound_run_journal_snapshot_args`) caps pathological payloads so the snapshot can't balloon. Fixes #6196. Thanks @franksong2702. (#6197, #6196)
|
||||
|
||||
- **The "Compressing context" card no longer flashes on turns that aren't actually compressing.** The card was driven by a broad status match (anything containing `compressing`), so skip/cooldown/defer notices and post-compression summaries (`Compressed X → Y`) could paint a phantom compression card mid-turn. A dedicated `_is_agent_compression_start_status()` now recognizes only the five genuine start-of-compression markers the Hermes agent emits, and the transcript renderer no longer lets an unrecognized `terminal_status` row paint a compression anchor. Real compression turns still show the card unchanged; verified against every current agent compression emitter. Thanks @carlotestor. (#6184)
|
||||
|
||||
- **Transparent Stream no longer shows the start of the final answer twice after a multi-segment turn settles.** In a turn with interim assistant prose interleaved with tool calls, the beginning of the final answer could render twice after the live→settled transition — once as a stale live-token accumulator snapshot, once as the real assistant segment — persisting until reload. The earlier suppression gate (#5758) computed its "after the last tool row" boundary over the combined row list, where settled per-message tool rows re-list tools that ran earlier in the turn, pushing the boundary past the final segment's accumulator so the dedupe never fired. Final-segment eligibility is now judged against the live projection's own chronology (a live-prose row belongs to the final segment only when no *projected* tool row follows it), so the stale prefix is dropped while pre-tool narration and every tool/thinking row are preserved. Thanks @ai-ag2026. (#6189, #5749)
|
||||
|
||||
- **The frontend now reads the `msg_limit` ceiling from `/api/session` metadata instead of hand-mirroring the constant.** Following the ceiling clamp shipped in exp-v0.52.98, the backend advertises its `_MAX_MSG_LIMIT` as `_msg_limit_max` in every `/api/session` response, and the frontend reads it dynamically (`_msgLimitMax`, a module-scope value defaulting to the static fallback), so the two can no longer drift and the mirrored constant's drift-guard test is no longer needed. Older backends that omit the field fall back to the built-in default, so mixed-version deployments are unaffected. Thanks @webtecnica. (#6214, #6177)
|
||||
|
||||
- **`GET /api/session?msg_limit=` is now bounded by a server-side ceiling, and the frontend paginates around it without dropping rows.** A client could request `msg_limit=1000000` (or an outline-jump path asked for `9999`) and force the server to assemble and serialize an unbounded message payload. The backend now clamps `?msg_limit=` to `[1, 500]` via a dedicated `_parse_msg_limit()` helper and sets the existing `_messages_truncated` signal when it clamps; the bare no-`msg_limit` path (branch/undo/jump-to-start) still returns the full transcript. The frontend mirrors the ceiling: `_loadOlderMessages` grows its tail window below the ceiling and switches to bounded `msg_before` backward paging once the server would clamp, and the outline jump uses the bare full-transcript path instead of the old `msg_limit=9999` hack. Two silent row-loss regressions found during review were fixed before ship: a raw-row-heavy `msg_before` page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (losing older rows), and a same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 — both now route through the correct paging/full-transcript path. Thanks @rh-id. (#6152, #6154, #6177)
|
||||
|
||||
- **Background subagents no longer fail to persist when the parent's cached agent is reused mid-turn.** A parent session's `SessionDB` handle is shared by reference with the background subagents it spawns (`delegate_tool`), but the cached-agent reuse path unconditionally closed that handle when the parent got a new turn / server-side wakeup — so every still-running child then failed `append_message` with `'NoneType' object has no attribute 'execute'` (observed 600+ times under one parent). The reuse path now keeps a still-open handle (closing only the unused fresh one, preserving the #1421 FD-leak fix) and only replaces a dead/missing one. The credential self-heal path was hardened the same way: if rebuilding the handle fails it degrades cleanly to a lazy reinit instead of reusing a closed handle. Thanks @carlotestor. (#6143)
|
||||
|
||||
- **The CLI/cron session-list projection cache is now bounded.** `_CLI_SESSIONS_CACHE` was an unbounded plain dict of deep-copied session-list projections whose fingerprint-folded key advances on every streamed message, so orphaned heavy copies accumulated on a long-lived server until the next structural clear. It's now an `OrderedDict` capped at 8 entries with drop-oldest LRU eviction (recency refresh on hit), mirroring the existing `_CLAUDE_CODE_PARSE_CACHE` / `_SIDECAR_METADATA_CACHE` pattern; TTL stays the freshness control. Thanks @rh-id. (#6140)
|
||||
|
||||
- **Models selected from a list-shaped custom-provider allowlist now route to the right provider.** A `custom_providers[].models` allowlist can be written as either a mapping or a list, and the model picker accepted both — but runtime routing only read mapping keys, so a model chosen from a *list*-shaped allowlist could silently stay on the active/default provider instead of its own. The supported-model-ID extraction is now centralized in one helper used by both catalog construction and routing, so the picker and runtime can no longer drift. Existing mapping-shaped and list-of-strings configs are unaffected. Thanks @franksong2702. (#6127, #6121)
|
||||
|
||||
- **Stale sidebar approval attention badge now clears once its gateway approval has drained.** A parent session could keep showing the red approval "attention" dot in the sidebar after the underlying gateway approval was already resolved/gone — the live gateway queue drained but the sidebar summary kept the mirrored approval alive. `_session_attention_summary()` now reconciles the gateway pending-mirror before counting attention (the same self-healing repair the approval-pending route already does), so the badge clears correctly. Live approvals stay visible and actionable and the clarify fallback is unchanged. This is the WebUI read-path mitigation; the child-session context-propagation root cause stays tracked in #6100. Thanks @rodboev. (#6117, #6100)
|
||||
|
||||
- **The mobile composer model picker no longer opens off-screen.** On phones the model dropdown could open below the fold (looking like it "won't open", issue #6080) because it lived inside `.composer-footer`, whose `container-type` (and, under the Geist Contrast skin, `backdrop-filter`) made the footer the containing block for the dropdown's fixed positioning — so it anchored to the bottom-of-screen footer instead of the viewport. The picker now reparents to `<body>` while open (the same idiom the profile dropdown already uses), so its fixed positioning resolves against the visual viewport on every skin; it flips above/below the anchor by available space, clamps below the titlebar, and re-positions as the on-screen keyboard / URL bar move the visual viewport. Desktop behavior is unchanged. Thanks @webtecnica. (#6105, #6080)
|
||||
|
||||
- **Approval and clarify popups no longer collapse to tiny blank windows on very narrow screens.** On very narrow viewports the clarify card could shrink to an unreadable sliver; it now gets a `min-width` and `overflow:visible` matching the approval card. Thanks @webtecnica. (#6104, #6094)
|
||||
|
||||
- **Stream event timestamps no longer break the row layout on narrow foldable screens.** On screens under 360px the per-event timestamps could spill out of tool/thinking card headers; the headers now clip overflow so the timestamp layout holds. (Tool names stay visible — an earlier draft hid them at ≤360px, which lost tool identity; the header clip alone prevents the spill.) Thanks @webtecnica. (#6106, #6099)
|
||||
|
||||
- **Streaming sidebar polls no longer re-run the expensive CLI/cron projection every few seconds.** The per-stream cache TTL was pinned at 30s while the frontend's streaming sidebar poll cadence could approach it, so the state.db CLI/cron projection (and its ~200 sidecar reads) could be re-paid on nearly every poll during a live turn (the #4842/#4808 high-CPU `get_cli_sessions` case). The streaming TTL is now held strictly above the sidebar poll interval (45s) and paired with the stable streaming cache key, so repeated polls reuse the projection; a new test asserts the TTL stays above the poll cadence so this can't silently regress. Thanks @starship-s. (#6102, #4842)
|
||||
|
||||
- **Delegated subagent sessions in the sidebar now show a meaningful title instead of "Subagent Session".** Generic delegated child sessions previously all displayed the placeholder "Subagent Session" in the conversations list, making them indistinguishable. Their sidebar title is now derived (view-only) from the child's first user message during the existing batched sidebar-metadata pass — without mutating the stored session title, so the read-only boundary is preserved. Only generic "Subagent Session" placeholders are affected; custom titles are left untouched. Thanks @rodboev. (#6056, #6033)
|
||||
|
||||
## [v0.52.76] — 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
+44
@@ -30,6 +30,50 @@ RUN apt-get update -y --fix-missing --no-install-recommends \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── SQLite upgrade ──────────────────────────────────────────────────────────
|
||||
# The python:3.12-slim base ships SQLite 3.46.1 (Debian Trixie), which is
|
||||
# vulnerable to the WAL-reset corruption bug discovered March 2026.
|
||||
# https://sqlite.org/wal.html#walresetbug
|
||||
#
|
||||
# Debian has not backported the fix, so we compile from the amalgamation.
|
||||
# Installs to /usr/local/lib (registered in ld.so.conf.d for arm64 priority).
|
||||
# Build tools are purged after compilation to keep the image lean.
|
||||
# Build args are for forward version bumps only (3.54+, etc.).
|
||||
# When bumping SQLITE_VERSION, recompute the SHA-256 from the official
|
||||
# download and update SQLITE_SHA256 accordingly.
|
||||
ARG SQLITE_VERSION=3530000
|
||||
ARG SQLITE_YEAR=2026
|
||||
ARG SQLITE_SHA256=851e9b38192fe2ceaa65e0baa665e7fa06230c3d9bd1a6a9662d02380d73365a
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc make libc6-dev \
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://sqlite.org/${SQLITE_YEAR}/sqlite-autoconf-${SQLITE_VERSION}.tar.gz" \
|
||||
-o sqlite.tar.gz \
|
||||
&& echo "${SQLITE_SHA256} sqlite.tar.gz" | sha256sum -c - \
|
||||
&& tar xzf sqlite.tar.gz \
|
||||
&& cd "sqlite-autoconf-${SQLITE_VERSION}" \
|
||||
&& CPPFLAGS="-DSQLITE_SECURE_DELETE" ./configure --prefix=/usr/local --disable-static --disable-readline \
|
||||
--enable-fts5 --enable-fts4 --enable-rtree \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install \
|
||||
&& echo "/usr/local/lib" > /etc/ld.so.conf.d/000-usr-local-lib.conf \
|
||||
&& /sbin/ldconfig \
|
||||
&& cd / && rm -rf /tmp/sqlite* \
|
||||
&& apt-get purge -y gcc make libc6-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -c "\
|
||||
import sqlite3; \
|
||||
v = sqlite3.sqlite_version; \
|
||||
assert tuple(int(x) for x in v.split('.')) >= (3, 51, 3), \
|
||||
f'SQLite {v} still vulnerable'; \
|
||||
c = sqlite3.connect(':memory:'); \
|
||||
assert c.execute('PRAGMA secure_delete').fetchone()[0] == 1, \
|
||||
'SQLITE_SECURE_DELETE not compiled in (deleted rows would remain recoverable)'; \
|
||||
c.execute('CREATE VIRTUAL TABLE _fts5_build_check USING fts5(x)'); \
|
||||
c.execute('DROP TABLE _fts5_build_check'); \
|
||||
c.close()"
|
||||
|
||||
# Optional GPU user-space acceleration libraries for users who pass through
|
||||
# host GPU devices. The default image remains CPU-only.
|
||||
ARG INSTALL_GPU_LIBS=0
|
||||
|
||||
@@ -294,6 +294,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Plain skills match case-insensitive keywords in their name or description; built-in, agent/plugin, and bundle commands keep prefix matching and take precedence over a same-slug skill
|
||||
- Built-in: `/help`, `/clear`, `/compress [focus topic]`, `/compact` (alias), `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
@@ -691,6 +692,7 @@ The WebUI is still coupled to Hermes Agent internals for runtime execution, prov
|
||||
- [`TESTING.md`](TESTING.md) — manual browser test plan and automated coverage reference
|
||||
- [`DESIGN.md`](DESIGN.md) — design tokens and the calm-console direction
|
||||
- [`docs/UIUX-GUIDE.md`](docs/UIUX-GUIDE.md) — UI/UX principles sourced from the design docs and visual inventories
|
||||
- [`docs/sse-streams.md`](docs/sse-streams.md) — cross-client SSE endpoint reference: session streaming, gateway SSE probe scope, heartbeats, and proxy behavior
|
||||
- [`docs/CONTRACTS.md`](docs/CONTRACTS.md) — project contract/RFC/design index for contributors and agents
|
||||
- [`docs/rfcs/README.md`](docs/rfcs/README.md) — RFC index for larger architecture and durability proposals
|
||||
|
||||
|
||||
+19
-2
@@ -111,6 +111,9 @@ python tests/browser_conversation_lifecycle.py
|
||||
|
||||
# Terminal-error lifecycle gate (new row in the proof matrix).
|
||||
LIFECYCLE_SCENARIO=terminal-error python tests/browser_conversation_lifecycle.py
|
||||
|
||||
# Historical ID-linked transcript hydration row.
|
||||
python tests/browser_historical_transcript_hydration.py
|
||||
```
|
||||
|
||||
To certify that the gate catches its target failure, the test owns an opt-in
|
||||
@@ -126,14 +129,28 @@ LIFECYCLE_TEST_BITE=drop-anchor-persistence \
|
||||
LIFECYCLE_SCENARIO=terminal-error \
|
||||
LIFECYCLE_TEST_BITE=drop-terminal-anchor-row \
|
||||
python tests/browser_conversation_lifecycle.py
|
||||
|
||||
# Historical-hydration mutation: corrupt one persisted tool-result link so the
|
||||
# strict Anchor projection must fail instead of claiming the legacy transcript.
|
||||
HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
|
||||
python tests/browser_historical_transcript_hydration.py
|
||||
```
|
||||
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs both current
|
||||
proof rows (`normal` and `terminal-error`) and stays non-blocking while the public
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs the current
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and
|
||||
stays non-blocking while the public
|
||||
matrix expands to additional behavior rows. The maintainer's private QA harness
|
||||
remains broader; later public slices will add session switching, reconnect/replay,
|
||||
cancellation, compression, and recovery.
|
||||
|
||||
### Streaming reader intent
|
||||
|
||||
While a response is still streaming, scroll upward with a trackpad or wheel to
|
||||
read earlier content, including a small scroll gesture immediately after a live
|
||||
render. Subsequent streamed content must not pull the reader back to the bottom.
|
||||
Use the jump-to-latest control to resume following the live tail; after that,
|
||||
new streamed content should remain visible at the bottom.
|
||||
|
||||
|
||||
`tests/test_static_js_runtime_lint.py` runs this automatically when eslint is present
|
||||
and **skips gracefully** (clear message) when it isn't — so environments without the
|
||||
|
||||
@@ -144,6 +144,36 @@ font size. Persists alongside theme and skin.
|
||||
|
||||
---
|
||||
|
||||
## Typography Tokens
|
||||
|
||||
The three font tokens are declared in `static/style.css`:
|
||||
|
||||
- `--font-ui`: user interface chrome, controls, composer chrome, labels, and
|
||||
ordinary non-code UI text
|
||||
- `--font-conversation`: user/assistant message prose
|
||||
- `--font-mono`: code, tool details, logs, identifiers, and terminal text
|
||||
|
||||
By default:
|
||||
|
||||
```css
|
||||
--font-conversation: var(--font-ui);
|
||||
--font-mono: ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
```
|
||||
|
||||
For CSS-based custom skins and extension stylesheets/scripts, override the three
|
||||
font tokens directly (`--font-ui`, `--font-conversation`, `--font-mono`) on the
|
||||
skin root (`:root[data-skin="..."]`), including any mode-specific variant.
|
||||
`window.registerHermesSkin()` currently accepts only the documented design-token
|
||||
allowlist (no `--font-*` tokens), so custom font families should be supplied via
|
||||
CSS rather than skin registration.
|
||||
|
||||
Leaving `--font-conversation` untouched keeps prose coupled to `--font-ui`;
|
||||
override it only when a different reading face is explicitly required. Avoid
|
||||
hard-coded selector-level font declarations when one of these tokens already
|
||||
expresses the intent.
|
||||
|
||||
---
|
||||
|
||||
## How It Works Internally
|
||||
|
||||
1. **Theme:** `document.documentElement.classList.toggle('dark', isDark)` —
|
||||
|
||||
+278
-6
@@ -8,19 +8,54 @@ mixed runtime and require a clean WebUI restart instead.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
||||
# runtime identity is deliberately captured from the loaded module below.
|
||||
from api.config import _AGENT_DIR # noqa: F401
|
||||
|
||||
_RESTART_MESSAGE = (
|
||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||
"Restart Hermes WebUI before retrying this action."
|
||||
from api.config import (
|
||||
PYTHON_EXE,
|
||||
_AGENT_DIR, # noqa: F401
|
||||
_DEFAULT_STATE_HOME,
|
||||
)
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
_RESTART_REQUIRED_MESSAGE = (
|
||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||
"WebUI cannot verify that the Agent update completed safely. "
|
||||
"Check the Agent update outcome and environment first. "
|
||||
"Restart Hermes WebUI manually before retrying this action."
|
||||
)
|
||||
_AGENT_UPDATE_MARKER = ".hermes-update-in-progress"
|
||||
_AGENT_RECOVERY_MARKERS = (".update-incomplete", ".lazy-refresh-incomplete")
|
||||
_AGENT_UPDATE_MAX_AGE_SECONDS = 20 * 60
|
||||
# The update marker holds a PID and a start timestamp (two short numeric lines).
|
||||
# Anything larger is not a legitimate marker; cap the read so a huge or growing
|
||||
# regular file can never exhaust memory on the stale-runtime request path.
|
||||
_AGENT_UPDATE_MARKER_MAX_BYTES = 64 * 1024
|
||||
# O_NOFOLLOW is POSIX; on platforms that lack it the fast os.open() path is not
|
||||
# taken at all (see _MARKER_SAFE_OPEN_AVAILABLE below).
|
||||
# The marker read hardening relies on two POSIX-only open flags to stay both
|
||||
# non-blocking (never hang on a FIFO/device) and symlink-safe. O_NONBLOCK is
|
||||
# Unix-only and O_NOFOLLOW is absent on some platforms; accessing them
|
||||
# unconditionally raises AttributeError on native Windows. Resolve them safely
|
||||
# and only take the os.open() fast path when BOTH are genuinely available —
|
||||
# otherwise the read cannot prove non-blocking + no-follow and must fall back to
|
||||
# an lstat-only classification (see _read_live_agent_update).
|
||||
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||
_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0)
|
||||
_MARKER_SAFE_OPEN_AVAILABLE = bool(getattr(os, "O_NOFOLLOW", 0)) and bool(
|
||||
getattr(os, "O_NONBLOCK", 0)
|
||||
)
|
||||
_HERMES_HOME = Path(_DEFAULT_STATE_HOME)
|
||||
_AGENT_PYTHON = Path(PYTHON_EXE).expanduser() if PYTHON_EXE else None
|
||||
|
||||
|
||||
def _read_agent_revision(
|
||||
@@ -49,6 +84,7 @@ def _read_agent_revision(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if worktree_result.returncode != 0:
|
||||
return None
|
||||
@@ -69,6 +105,7 @@ def _read_agent_revision(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if tracked_result.returncode != 0:
|
||||
return None
|
||||
@@ -78,6 +115,7 @@ def _read_agent_revision(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
||||
return None
|
||||
@@ -96,6 +134,233 @@ _RUNTIME_LOCK = threading.Lock()
|
||||
class AgentRuntimeChangedError(RuntimeError):
|
||||
"""Raised when the loaded Agent runtime no longer matches its source tree."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
agent_update_state: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.agent_update_state = agent_update_state
|
||||
|
||||
|
||||
def agent_runtime_stale_payload(exc: AgentRuntimeChangedError) -> dict:
|
||||
"""Return the shared retry response for every stale-runtime entry point."""
|
||||
payload = {
|
||||
"error": str(exc),
|
||||
"type": "agent_runtime_stale",
|
||||
"retryable": True,
|
||||
"restart_scheduled": False,
|
||||
}
|
||||
if exc.agent_update_state is not None:
|
||||
payload["agent_update_state"] = exc.agent_update_state
|
||||
return payload
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int) -> bool | None:
|
||||
"""Return PID liveness, or ``None`` when the platform cannot confirm it."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
if pid.bit_length() > 32:
|
||||
return None
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = (
|
||||
wintypes.DWORD,
|
||||
wintypes.BOOL,
|
||||
wintypes.DWORD,
|
||||
)
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = (
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
)
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
handle = kernel32.OpenProcess(0x1000, False, pid)
|
||||
if not handle:
|
||||
error = ctypes.get_last_error()
|
||||
if error == 5: # ERROR_ACCESS_DENIED still proves the PID exists.
|
||||
return True
|
||||
if error == 87: # ERROR_INVALID_PARAMETER for a missing PID.
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return None
|
||||
return exit_code.value == 259 # STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
except (AttributeError, OSError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (OverflowError, ValueError):
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ESRCH:
|
||||
return False
|
||||
if exc.errno == errno.EPERM:
|
||||
return True
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def _read_live_agent_update(marker: Path) -> str:
|
||||
"""Classify the shared Agent update marker without changing Agent state.
|
||||
|
||||
The marker is attacker-adjacent shared state (any process that can write the
|
||||
Agent home can create it), so the read is hardened: never follow a symlink,
|
||||
never block on a FIFO/device, and never read an unbounded regular file.
|
||||
Anything that is not a small regular file is classified ``unknown`` rather
|
||||
than allowed to hang or exhaust memory on a stale-runtime request path.
|
||||
"""
|
||||
if not _MARKER_SAFE_OPEN_AVAILABLE:
|
||||
# Without both O_NONBLOCK and O_NOFOLLOW we cannot prove the read is
|
||||
# non-blocking and symlink-safe (e.g. native Windows), so never open the
|
||||
# marker: an unverifiable marker fails closed to ``unknown``, and only a
|
||||
# genuinely missing path is ``absent``.
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except (OSError, ValueError, TypeError):
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
try:
|
||||
fd = os.open(marker, os.O_RDONLY | _O_NONBLOCK | _O_NOFOLLOW)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except OSError:
|
||||
return "unknown"
|
||||
# Path exists to lstat (e.g. a dangling/looping symlink) but O_NOFOLLOW
|
||||
# refused to open it — treat as an unverifiable marker.
|
||||
return "unknown"
|
||||
except (OSError, ValueError, TypeError):
|
||||
# ELOOP (symlink under O_NOFOLLOW), ENXIO/EWOULDBLOCK (FIFO with no
|
||||
# writer under O_NONBLOCK), a non-path marker object, or any other open
|
||||
# failure — fail closed: an unreadable marker is never proof of safety.
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
except OSError:
|
||||
return "unknown"
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
# FIFO, device, directory, socket — never a legitimate marker.
|
||||
return "unknown"
|
||||
if st.st_size > _AGENT_UPDATE_MARKER_MAX_BYTES:
|
||||
return "unknown"
|
||||
try:
|
||||
# Read one byte past the cap so an oversized file that lied about
|
||||
# st_size (or grew mid-read) is still rejected rather than truncated.
|
||||
data = os.read(fd, _AGENT_UPDATE_MARKER_MAX_BYTES + 1)
|
||||
except (OSError, BlockingIOError):
|
||||
return "unknown"
|
||||
finally:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if len(data) > _AGENT_UPDATE_MARKER_MAX_BYTES:
|
||||
return "unknown"
|
||||
try:
|
||||
raw = data.decode("utf-8")
|
||||
except UnicodeError:
|
||||
return "unknown"
|
||||
|
||||
lines = raw.splitlines()
|
||||
try:
|
||||
pid = int(lines[0].strip())
|
||||
started_at = float(lines[1].strip())
|
||||
except (IndexError, TypeError, ValueError):
|
||||
return "unknown"
|
||||
if pid <= 0 or not math.isfinite(started_at):
|
||||
return "unknown"
|
||||
|
||||
age_seconds = time.time() - started_at
|
||||
if age_seconds < 0:
|
||||
return "unknown"
|
||||
if age_seconds > _AGENT_UPDATE_MAX_AGE_SECONDS:
|
||||
return "stale"
|
||||
alive = _pid_is_alive(pid)
|
||||
if alive is None:
|
||||
return "unknown"
|
||||
return "active" if alive else "stale"
|
||||
|
||||
|
||||
def _marker_presence(marker: Path) -> str:
|
||||
"""Return ``present``, ``absent``, or ``unknown`` for a recovery marker."""
|
||||
try:
|
||||
marker.lstat()
|
||||
except FileNotFoundError:
|
||||
return "absent"
|
||||
except OSError:
|
||||
return "unknown"
|
||||
return "present"
|
||||
|
||||
|
||||
def _agent_install_roots() -> tuple[Path, ...]:
|
||||
"""Return portable roots that can own the Agent's venv recovery markers."""
|
||||
candidates: list[Path] = []
|
||||
if _AGENT_SOURCE_DIR is not None:
|
||||
candidates.append(_AGENT_SOURCE_DIR)
|
||||
if _AGENT_PYTHON is not None:
|
||||
# A venv Python is commonly a symlink to a shared interpreter. Keep the
|
||||
# configured venv path so its installation's recovery markers are read.
|
||||
python_path = _AGENT_PYTHON
|
||||
if python_path.parent.name.lower() in {"bin", "scripts"}:
|
||||
venv_dir = python_path.parent.parent
|
||||
if venv_dir.name.lower() in {"venv", ".venv"}:
|
||||
candidates.append(venv_dir.parent)
|
||||
|
||||
roots: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for candidate in candidates:
|
||||
key = os.path.normcase(os.path.abspath(str(candidate)))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
roots.append(candidate)
|
||||
return tuple(roots)
|
||||
|
||||
|
||||
def _agent_update_transaction_state() -> str:
|
||||
"""Report marker diagnostics, never proof of successful completion.
|
||||
|
||||
The Agent removes its active marker on failed/interrupted exits too. Neither
|
||||
its absence nor a stale PID proves the checkout or environment is healthy.
|
||||
"""
|
||||
live_state = _read_live_agent_update(_HERMES_HOME / _AGENT_UPDATE_MARKER)
|
||||
if live_state == "unknown":
|
||||
return "unknown"
|
||||
|
||||
recovery_present = False
|
||||
for root in _agent_install_roots():
|
||||
for marker_name in _AGENT_RECOVERY_MARKERS:
|
||||
presence = _marker_presence(root / marker_name)
|
||||
if presence == "unknown":
|
||||
return "unknown"
|
||||
recovery_present = recovery_present or presence == "present"
|
||||
if recovery_present:
|
||||
return "incomplete"
|
||||
return "unverified" if live_state == "absent" else live_state
|
||||
|
||||
|
||||
def _loaded_agent_source_identity() -> tuple[Path, Path] | None:
|
||||
"""Return the source directory and file that supplied ``run_agent``."""
|
||||
@@ -136,7 +401,14 @@ def ensure_agent_runtime_current() -> None:
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
raise AgentRuntimeChangedError(_RESTART_MESSAGE)
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
|
||||
+78
-8
@@ -6,6 +6,17 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# state.db paths that already produced the "no 'source' column" warning below.
|
||||
# ``get_cli_sessions(all_profiles=True)`` re-reads every profile DB on every
|
||||
# sidebar poll (behind a 5 s cache), so a single pre-``source`` profile DB would
|
||||
# otherwise re-emit the identical WARNING line every ~15 s for the life of the
|
||||
# process. The condition is a property of the DB file, not of the poll, so it
|
||||
# is reported once per path. Process-lifetime only: a restart warns again,
|
||||
# which is the desired behaviour (the log line is the operator's cue that the
|
||||
# agent still needs upgrading). Plain ``set`` mutation under the GIL is
|
||||
# sufficient here; a duplicate line from two racing first calls is harmless.
|
||||
_SOURCE_COLUMN_WARNED_DB_PATHS: set[str] = set()
|
||||
|
||||
|
||||
def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> sqlite3.Connection:
|
||||
"""Open the live agent ``state.db`` read-only for a pure-read projection.
|
||||
@@ -47,6 +58,8 @@ MESSAGING_SOURCES = {
|
||||
'slack',
|
||||
'telegram',
|
||||
'weixin',
|
||||
'matrix',
|
||||
'signal',
|
||||
}
|
||||
|
||||
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
|
||||
@@ -59,6 +72,7 @@ SOURCE_LABELS = {
|
||||
'cron': 'Cron',
|
||||
'discord': 'Discord',
|
||||
'email': 'Email',
|
||||
'kanban': 'Kanban',
|
||||
'wecom': 'WeCom',
|
||||
'wecom_callback': 'WeCom Callback',
|
||||
'slack': 'Slack',
|
||||
@@ -68,6 +82,8 @@ SOURCE_LABELS = {
|
||||
'webhook': 'Webhook',
|
||||
'webui': 'WebUI',
|
||||
'weixin': 'Weixin',
|
||||
'matrix': 'Matrix',
|
||||
'signal': 'Signal',
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +111,8 @@ def normalize_agent_session_source(raw_source: str | None) -> dict:
|
||||
session_source = 'cron'
|
||||
elif raw == 'webhook':
|
||||
session_source = 'webhook'
|
||||
elif raw == 'kanban':
|
||||
session_source = 'kanban'
|
||||
elif raw == 'tool':
|
||||
session_source = 'tool'
|
||||
elif raw == 'api_server':
|
||||
@@ -218,7 +236,7 @@ def is_cli_session_row(row: dict) -> bool:
|
||||
# runner, never a writable WebUI/CLI session (#5307). Classify it non-CLI so
|
||||
# sidebar rows and every is_cli_session_row() consumer keep it out of the
|
||||
# CLI/writable treatment.
|
||||
non_cli_sources = MESSAGING_SOURCES | {"cron", "webhook", "tool", "api", "api_server", "subagent"}
|
||||
non_cli_sources = MESSAGING_SOURCES | {"cron", "webhook", "kanban", "tool", "api", "api_server", "subagent"}
|
||||
if {source, source_tag, raw_source, source_name, source_label} & non_cli_sources:
|
||||
return False
|
||||
if source == "messaging":
|
||||
@@ -512,6 +530,19 @@ def read_importable_agent_session_rows(
|
||||
``exclude_sources=None``. ``include_sources`` is an additional narrowing
|
||||
filter; callers that want an include-only query should explicitly pass
|
||||
``exclude_sources=None`` so the default exclusions do not also apply.
|
||||
|
||||
``limit`` bounds the *recency slice*, not the returned row count. Subagent
|
||||
rows only render as children when their parent row is in the same payload,
|
||||
so subagent ancestors of selected rows are re-added afterwards and the
|
||||
result can exceed ``limit`` by the number of such anchors. Callers must
|
||||
therefore iterate the result rather than assume ``len(rows) <= limit``.
|
||||
|
||||
That recovery is deliberately bounded by the oversampled candidate set
|
||||
(``limit * 8`` newest sessions): it re-uses rows the projection already
|
||||
fetched and never issues an extra query, so an ancestor older than the
|
||||
oversample stays unresolved and its children render top-level, exactly as
|
||||
they did before. Widening that window is a ``candidate_limit`` change, not
|
||||
a change to this walk.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
@@ -544,12 +575,15 @@ def read_importable_agent_session_rows(
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
message_cols = {row[1] for row in cur.fetchall()}
|
||||
if 'source' not in session_cols:
|
||||
log.warning(
|
||||
"agent session listing skipped: state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). Agent sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
warned_key = str(db_path.resolve())
|
||||
if warned_key not in _SOURCE_COLUMN_WARNED_DB_PATHS:
|
||||
_SOURCE_COLUMN_WARNED_DB_PATHS.add(warned_key)
|
||||
log.warning(
|
||||
"agent session listing skipped: state.db at %s has no 'source' column "
|
||||
"(older hermes-agent?). Agent sessions unavailable. "
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return []
|
||||
|
||||
parent_expr = _optional_col('parent_session_id', session_cols)
|
||||
@@ -761,7 +795,43 @@ def read_importable_agent_session_rows(
|
||||
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
||||
if limit is None:
|
||||
return projected
|
||||
return projected[:max(0, int(limit))]
|
||||
selected = projected[:max(0, int(limit))]
|
||||
|
||||
# The recency slice is per-row, but subagent rows are only renderable as
|
||||
# children: the sidebar nests a child under its parent solely when that
|
||||
# parent row is present in the same payload. A frozen orchestrator stops
|
||||
# writing while its leaves keep streaming, so the leaves win the recency
|
||||
# race and the parent falls outside the window — leaving the leaves to be
|
||||
# promoted to top-level sidebar rows. Re-add subagent parents that the
|
||||
# oversampled candidate set already projected (no extra query); webui
|
||||
# ancestors are left out because that sidebar bucket already has them.
|
||||
#
|
||||
# Bounded by construction: ``by_id`` only holds the ``limit * 8``
|
||||
# newest candidates, so an ancestor older than that oversample is not
|
||||
# recovered and its children stay top-level — the pre-existing
|
||||
# behaviour, narrowed rather than fixed. Resolving those would need an
|
||||
# unbounded per-row ancestor query on the hot sidebar path; widen
|
||||
# ``candidate_limit`` instead if the window proves too tight.
|
||||
# NOTE: this can return more than ``limit`` rows (see docstring).
|
||||
have = {row.get('id') for row in selected}
|
||||
by_id = {row.get('id'): row for row in projected if row.get('id')}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
parent_id = row.get('parent_session_id')
|
||||
if not parent_id or parent_id in have:
|
||||
continue
|
||||
parent = by_id.get(parent_id)
|
||||
if parent is None:
|
||||
continue
|
||||
if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
selected.append(parent)
|
||||
have.add(parent_id)
|
||||
pending.append(parent)
|
||||
return selected
|
||||
|
||||
|
||||
|
||||
|
||||
+63
-19
@@ -105,6 +105,12 @@ _TRUSTED_AUTH_HEADER_ENV = 'HERMES_WEBUI_TRUSTED_AUTH_HEADER'
|
||||
_TRUSTED_GROUPS_HEADER_ENV = 'HERMES_WEBUI_TRUSTED_GROUPS_HEADER'
|
||||
_TRUSTED_GROUP_PROFILE_MAP_ENV = 'HERMES_WEBUI_GROUP_PROFILE_MAP'
|
||||
_TRUSTED_AUTH_LOGOUT_URL_ENV = 'HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL'
|
||||
# Opt-in: also treat '|' as a group separator in the trusted-groups header.
|
||||
# Off by default so an existing deployment whose group NAME legitimately
|
||||
# contains a literal '|' is never silently re-split into two groups (which
|
||||
# could change its profile binding). Set to 1/true/yes/on for identity
|
||||
# providers (some Authentik outpost configs) that emit "admins|developpeur".
|
||||
_TRUSTED_GROUPS_PIPE_SEPARATOR_ENV = 'HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR'
|
||||
_TRUSTED_AUTH_WARNINGS_EMITTED: set[str] = set()
|
||||
|
||||
|
||||
@@ -493,7 +499,8 @@ def is_oidc_auth_enabled() -> bool:
|
||||
|
||||
|
||||
def get_oidc_startup_warning() -> str | None:
|
||||
"""Return a startup warning when OIDC auth is only partially configured."""
|
||||
"""Return a startup warning when OIDC auth is only partially configured,
|
||||
or when allow_values uses whitespace that is no longer a separator."""
|
||||
try:
|
||||
cfg = get_config()
|
||||
raw = cfg.get("webui_oidc") if isinstance(cfg, dict) else {}
|
||||
@@ -511,28 +518,52 @@ def get_oidc_startup_warning() -> str | None:
|
||||
issuer = bool(pick("issuer", "HERMES_WEBUI_OIDC_ISSUER"))
|
||||
client_id = bool(pick("client_id", "HERMES_WEBUI_OIDC_CLIENT_ID"))
|
||||
allow_claim = bool(pick("allow_claim", "HERMES_WEBUI_OIDC_ALLOW_CLAIM"))
|
||||
allow_values = bool(pick("allow_values", "HERMES_WEBUI_OIDC_ALLOW_VALUES"))
|
||||
raw_allow_env = os.getenv("HERMES_WEBUI_OIDC_ALLOW_VALUES")
|
||||
raw_allow = raw_allow_env if raw_allow_env is not None else raw.get("allow_values")
|
||||
normalized_allow_values = []
|
||||
allow_values_warning = None
|
||||
try:
|
||||
from api import auth_oidc
|
||||
|
||||
normalized_allow_values = auth_oidc._normalize_allow_values(raw_allow)
|
||||
allow_values_warning = auth_oidc._ALLOW_VALUES_WHITESPACE_WARNING
|
||||
except Exception:
|
||||
logger.debug("Failed to normalize OIDC allow_values", exc_info=True)
|
||||
allow_values = bool(normalized_allow_values)
|
||||
|
||||
if not any((issuer, client_id, allow_claim, allow_values)):
|
||||
return None
|
||||
if issuer and client_id and allow_claim and allow_values:
|
||||
return None
|
||||
|
||||
missing = []
|
||||
if not issuer:
|
||||
missing.append("issuer")
|
||||
if not client_id:
|
||||
missing.append("client_id")
|
||||
if not allow_claim:
|
||||
missing.append("allow_claim")
|
||||
if not allow_values:
|
||||
missing.append("allow_values")
|
||||
warnings = []
|
||||
|
||||
joined = ", ".join(missing)
|
||||
return (
|
||||
"Native OIDC login is only partially configured; missing "
|
||||
f"{joined}. The WebUI will not enable OIDC auth until all four fields are set."
|
||||
)
|
||||
if not (issuer and client_id and allow_claim and allow_values):
|
||||
missing = []
|
||||
if not issuer:
|
||||
missing.append("issuer")
|
||||
if not client_id:
|
||||
missing.append("client_id")
|
||||
if not allow_claim:
|
||||
missing.append("allow_claim")
|
||||
if not allow_values:
|
||||
missing.append("allow_values")
|
||||
joined = ", ".join(missing)
|
||||
warnings.append(
|
||||
"Native OIDC login is only partially configured; missing "
|
||||
f"{joined}. The WebUI will not enable OIDC auth until all four fields are set."
|
||||
)
|
||||
|
||||
# Detect whitespace-only allow_values scalar that may contain multiple intended values.
|
||||
# Runs unconditionally so the warning reaches startup even when other auth methods
|
||||
# short-circuit is_auth_enabled() before the OIDC branch is evaluated.
|
||||
if (
|
||||
allow_values_warning is not None
|
||||
and raw_allow is not None
|
||||
and not isinstance(raw_allow, (list, tuple, set))
|
||||
and any(any(ch.isspace() for ch in v) for v in normalized_allow_values)
|
||||
):
|
||||
warnings.append(allow_values_warning)
|
||||
|
||||
return "\n".join(warnings) if warnings else None
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
@@ -697,7 +728,20 @@ def _trusted_groups_header_value(handler) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
values = []
|
||||
for part in str(raw).replace('\n', ',').split(','):
|
||||
# Authentik's outpost typically joins multiple group names with a comma or
|
||||
# newline; parse those as separators by default. Some proxy provider /
|
||||
# property-mapping configs instead emit a pipe-separated list (e.g.
|
||||
# "admins|developpeur"), which a comma-only split would treat as one
|
||||
# unmatched group name — silently dropping the session to the unbound
|
||||
# "default" profile despite a legitimate mapped membership. Pipe splitting
|
||||
# is therefore available but OPT-IN (HERMES_WEBUI_TRUSTED_GROUPS_PIPE_SEPARATOR),
|
||||
# because a group NAME can legitimately contain a literal '|' and must not be
|
||||
# re-split by default — doing so unconditionally could change an existing
|
||||
# deployment's profile binding.
|
||||
normalized = str(raw).replace('\n', ',')
|
||||
if str(os.getenv(_TRUSTED_GROUPS_PIPE_SEPARATOR_ENV, '')).strip().lower() in ('1', 'true', 'yes', 'on'):
|
||||
normalized = normalized.replace('|', ',')
|
||||
for part in normalized.split(','):
|
||||
part = part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
|
||||
+21
-3
@@ -38,6 +38,16 @@ _discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_jwks_lock = threading.Lock()
|
||||
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
_warned_allow_values: set[str] = set()
|
||||
|
||||
_ALLOW_VALUES_WHITESPACE_WARNING = (
|
||||
"webui_oidc.allow_values (HERMES_WEBUI_OIDC_ALLOW_VALUES) has one or more entries "
|
||||
"with internal whitespace; whitespace is not a value separator, so a value like "
|
||||
'"alice@example.com bob@example.com" is treated as a single entry. '
|
||||
'Use a comma-delimited scalar (e.g. "value1,value2") or a YAML array. '
|
||||
"If this is one intentional multi-word group, it is already correct and no action is needed."
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
@@ -166,9 +176,17 @@ def _resolve_oidc_config() -> dict[str, Any]:
|
||||
return env_value if env_value is not None else raw.get(name)
|
||||
|
||||
scopes = _normalize_scopes(pick("scopes", "HERMES_WEBUI_OIDC_SCOPES"))
|
||||
allow_values = _normalize_allow_values(
|
||||
pick("allow_values", "HERMES_WEBUI_OIDC_ALLOW_VALUES")
|
||||
)
|
||||
raw_allow = pick("allow_values", "HERMES_WEBUI_OIDC_ALLOW_VALUES")
|
||||
allow_values = _normalize_allow_values(raw_allow)
|
||||
if (
|
||||
raw_allow is not None
|
||||
and not isinstance(raw_allow, (list, tuple, set))
|
||||
and any(any(ch.isspace() for ch in v) for v in allow_values)
|
||||
):
|
||||
key = str(raw_allow)
|
||||
if key not in _warned_allow_values:
|
||||
_warned_allow_values.add(key)
|
||||
logger.warning(_ALLOW_VALUES_WHITESPACE_WARNING)
|
||||
return {
|
||||
"issuer": str(pick("issuer", "HERMES_WEBUI_OIDC_ISSUER") or "").strip(),
|
||||
"client_id": str(pick("client_id", "HERMES_WEBUI_OIDC_CLIENT_ID") or "").strip(),
|
||||
|
||||
+136
-48
@@ -271,6 +271,77 @@ def subscribe_to_session_channel(
|
||||
return ch, q
|
||||
|
||||
|
||||
# Bounded window a cancelling worker may stay lifecycle-busy before an entry
|
||||
# with no live SSE channel is treated as an orphan. Matches the unwind ceiling
|
||||
# used by the chat-start successor guard in ``api.routes``.
|
||||
_ACTIVE_RUN_CANCEL_UNWIND_SECONDS = 180.0
|
||||
|
||||
|
||||
def _active_run_ids_for_session(
|
||||
session_id: str,
|
||||
*,
|
||||
attachable_only: bool,
|
||||
) -> list[str]:
|
||||
"""Return this session's run stream ids under the requested semantics.
|
||||
|
||||
``attachable_only`` selects browser-recovery semantics and drops every
|
||||
``phase="cancelling"`` row: cancellation is already terminal for the client
|
||||
even while the worker unwinds. Busy checks pass ``False`` so a freshly
|
||||
cancelled run still blocks a successor for its bounded unwind window.
|
||||
|
||||
A cancelling row past that window with no live ``STREAMS`` channel is an
|
||||
orphan: it is dropped from ``ACTIVE_RUNS`` and its stream-owner entry is
|
||||
released, so a wedged worker cannot suppress background wakeups forever.
|
||||
"""
|
||||
from api import config as _cfg
|
||||
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return []
|
||||
try:
|
||||
with _cfg.STREAMS_LOCK:
|
||||
live_stream_ids = set((_cfg.STREAMS or {}).keys())
|
||||
now = time.time()
|
||||
matches: list[str] = []
|
||||
stale_keys: list[str] = []
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
for run_key, meta in list((_cfg.ACTIVE_RUNS or {}).items()):
|
||||
if not isinstance(meta, dict) or meta.get("session_id") != sid:
|
||||
continue
|
||||
stream_id = str(meta.get("stream_id") or run_key or "").strip()
|
||||
if not stream_id:
|
||||
continue
|
||||
cancelling = not _cfg.active_run_is_attachable(meta)
|
||||
stale_cancel = (
|
||||
cancelling
|
||||
and _cfg.active_run_cancel_is_stale(
|
||||
meta,
|
||||
grace_seconds=_ACTIVE_RUN_CANCEL_UNWIND_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
and run_key not in live_stream_ids
|
||||
and stream_id not in live_stream_ids
|
||||
)
|
||||
if stale_cancel:
|
||||
stale_keys.append(run_key)
|
||||
continue
|
||||
if attachable_only and cancelling:
|
||||
continue
|
||||
matches.append(stream_id)
|
||||
for run_key in stale_keys:
|
||||
(_cfg.ACTIVE_RUNS or {}).pop(run_key, None)
|
||||
for run_key in stale_keys:
|
||||
_cfg.unregister_stream_owner(run_key)
|
||||
return matches
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"ACTIVE_RUNS lookup failed for %s",
|
||||
sid,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def active_stream_id_for_session(session_id: str) -> Optional[str]:
|
||||
"""Return the stream_id of the live run for *session_id*, or None.
|
||||
|
||||
@@ -288,25 +359,14 @@ def active_stream_id_for_session(session_id: str) -> Optional[str]:
|
||||
|
||||
Keys on ACTIVE_RUNS (worker-lifecycle registry) — the same source
|
||||
``_session_has_active_turn`` / ``_emit_to_session_streams`` already trust
|
||||
to map a stream back to its owning session. Returns the first matching
|
||||
stream_id (a session has at most one live run; cancel/reconnect can
|
||||
briefly hold two — either is a valid attach target, the frontend dedupes
|
||||
by stream_id).
|
||||
to map a stream back to its owning session — but returns only rows that
|
||||
are still ATTACHABLE. A ``phase="cancelling"`` row stays lifecycle-busy
|
||||
while its worker unwinds, yet the client already reached a terminal state
|
||||
for that run, so replaying ``server_turn_started`` for it makes the tab
|
||||
attach, receive the terminal event, resubscribe, and loop forever.
|
||||
"""
|
||||
from api import config as _cfg
|
||||
|
||||
try:
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
for _stream_id, meta in (_cfg.ACTIVE_RUNS or {}).items():
|
||||
if isinstance(meta, dict) and meta.get("session_id") == session_id:
|
||||
return str(_stream_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"active_stream_id_for_session lookup failed for %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
matches = _active_run_ids_for_session(session_id, attachable_only=True)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def persisted_message_count_for_session(session_id: str) -> Optional[int]:
|
||||
@@ -908,8 +968,19 @@ def _requeue_async_delegation_event(
|
||||
)
|
||||
|
||||
|
||||
def _retry_unmapped_async_delegation_event(process_registry, evt: dict) -> None:
|
||||
"""Retry durable routing, or one bounded best-effort legacy routing pass."""
|
||||
def _retry_unclaimed_async_delegation_event(
|
||||
process_registry,
|
||||
evt: dict,
|
||||
*,
|
||||
keep_legacy_retrying: bool = False,
|
||||
) -> None:
|
||||
"""Retry from durable state, or make a bounded legacy routing pass.
|
||||
|
||||
``keep_legacy_retrying`` is reserved for a completion whose target session
|
||||
is known but currently busy. That wait state is neither an unroutable event
|
||||
nor a failed delivery attempt, so compatibility-mode delivery must keep
|
||||
backing off until the session becomes idle.
|
||||
"""
|
||||
completion_queue = getattr(process_registry, "completion_queue", None)
|
||||
if schedule_async_delegation_claim_retry(
|
||||
evt,
|
||||
@@ -917,10 +988,11 @@ def _retry_unmapped_async_delegation_event(process_registry, evt: dict) -> None:
|
||||
delay=ASYNC_DELIVERY_ROUTING_RETRY_SECONDS,
|
||||
):
|
||||
return
|
||||
if evt.get("_webui_routing_retry_attempted"):
|
||||
if not keep_legacy_retrying and evt.get("_webui_routing_retry_attempted"):
|
||||
return
|
||||
retry_evt = dict(evt)
|
||||
retry_evt["_webui_routing_retry_attempted"] = True
|
||||
if not keep_legacy_retrying:
|
||||
retry_evt["_webui_routing_retry_attempted"] = True
|
||||
_requeue_async_delegation_event(
|
||||
process_registry,
|
||||
retry_evt,
|
||||
@@ -988,7 +1060,9 @@ def _start_async_delegation_wakeup_turn(
|
||||
return
|
||||
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_requeue_async_delegation_event(process_registry, evt, claim=claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
if status == 409 and (resp or {}).get("error") == "process_wakeup_paused":
|
||||
logger.info(
|
||||
"async delegation wakeup paused for session %s; delivery remains retryable",
|
||||
@@ -997,16 +1071,18 @@ def _start_async_delegation_wakeup_turn(
|
||||
else:
|
||||
logger.debug(
|
||||
"async delegation wakeup not accepted for session %s: "
|
||||
"status=%s err=%r; requeued",
|
||||
"status=%s err=%r; durable retry scheduled",
|
||||
session_id,
|
||||
status,
|
||||
(resp or {}).get("error"),
|
||||
)
|
||||
except Exception:
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_requeue_async_delegation_event(process_registry, evt, claim=claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
logger.warning(
|
||||
"async delegation wakeup turn failed for session %s; requeued",
|
||||
"async delegation wakeup turn failed for session %s; durable retry scheduled",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -1027,6 +1103,17 @@ def _process_async_delegation_event(
|
||||
) -> None:
|
||||
"""Claim and route one async completion without private registry markers."""
|
||||
|
||||
# A durable claim has a finite attempt budget. A busy foreground turn is
|
||||
# not a delivery attempt, so leave the record unclaimed and let the shared
|
||||
# restore sweep retry after the session can accept a wakeup.
|
||||
if _session_has_active_turn(session_id):
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry,
|
||||
evt,
|
||||
keep_legacy_retrying=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
claim = claim_async_delegation_delivery(evt, "webui-background")
|
||||
except Exception:
|
||||
@@ -1042,15 +1129,20 @@ def _process_async_delegation_event(
|
||||
wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else ""
|
||||
if not wakeup_prompt:
|
||||
raise RuntimeError("async delegation completion could not be formatted")
|
||||
except Exception:
|
||||
# A formatting failure is a permanent, non-retryable defect (a malformed
|
||||
# event will never format correctly), so it must stay on the bounded
|
||||
# one-shot path — never keep_legacy_retrying, or it would loop forever.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(process_registry, evt)
|
||||
logger.warning(
|
||||
"async delegation completion could not be formatted for session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Do not persist async results in the process-local deferred list. If a
|
||||
# foreground turn owns the session, release the durable claim and retry
|
||||
# from the shared queue; the core record therefore remains restart-safe.
|
||||
if _session_has_active_turn(session_id):
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_requeue_async_delegation_event(process_registry, evt, claim=claim)
|
||||
return
|
||||
|
||||
try:
|
||||
_start_async_delegation_wakeup_turn(
|
||||
session_id,
|
||||
wakeup_prompt,
|
||||
@@ -1060,8 +1152,13 @@ def _process_async_delegation_event(
|
||||
process_registry=process_registry,
|
||||
)
|
||||
except Exception:
|
||||
# A post-format dispatch failure against a resolved target is transient
|
||||
# (the target may accept on a later pass), so keep the legacy completion
|
||||
# retrying rather than dropping it after one bounded attempt.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_requeue_async_delegation_event(process_registry, evt, claim=claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
logger.warning(
|
||||
"server-side async delegation dispatch failed for session %s",
|
||||
session_id,
|
||||
@@ -1144,7 +1241,7 @@ def _process_one(evt: dict) -> None:
|
||||
process_id,
|
||||
)
|
||||
if evt.get("type") == "async_delegation":
|
||||
_retry_unmapped_async_delegation_event(_process_registry, evt)
|
||||
_retry_unclaimed_async_delegation_event(_process_registry, evt)
|
||||
return
|
||||
session_id = ""
|
||||
if session_key:
|
||||
@@ -1157,7 +1254,7 @@ def _process_one(evt: dict) -> None:
|
||||
# registered shortly after process restore.
|
||||
logger.debug("process_complete drop: no session mapping for key=%r", session_key)
|
||||
if evt.get("type") == "async_delegation":
|
||||
_retry_unmapped_async_delegation_event(_process_registry, evt)
|
||||
_retry_unclaimed_async_delegation_event(_process_registry, evt)
|
||||
return
|
||||
# ── xsession wakeup misroute defense-in-depth (Option 3) ──────────────
|
||||
# First retain the process-registry spawn-owner cross-check for legacy
|
||||
@@ -1186,7 +1283,7 @@ def _process_one(evt: dict) -> None:
|
||||
# the bounded retry instead (arms a durable retry / one best-effort
|
||||
# legacy requeue), so it stays retryable without a spurious ACK.
|
||||
if evt.get("type") == "async_delegation":
|
||||
_retry_unmapped_async_delegation_event(_process_registry, evt)
|
||||
_retry_unclaimed_async_delegation_event(_process_registry, evt)
|
||||
return
|
||||
# ── THE SEAM: async delegations take the durable-claim delivery path,
|
||||
# routed to the origin-resolved session. The claim/complete/release
|
||||
@@ -1481,16 +1578,7 @@ def _session_has_active_turn(session_id: str) -> bool:
|
||||
``_start_chat_stream_for_session``'s own active-stream guard — i.e. the
|
||||
same lock /api/chat/start uses is the authoritative race backstop.
|
||||
"""
|
||||
from api import config as _cfg
|
||||
|
||||
try:
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
for _stream_id, meta in (_cfg.ACTIVE_RUNS or {}).items():
|
||||
if isinstance(meta, dict) and meta.get("session_id") == session_id:
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("ACTIVE_RUNS active-turn check failed", exc_info=True)
|
||||
return False
|
||||
return bool(_active_run_ids_for_session(session_id, attachable_only=False))
|
||||
|
||||
|
||||
def _start_server_side_wakeup_turn(
|
||||
|
||||
+8
-3
@@ -86,11 +86,16 @@ def clear_pending(session_key: str) -> int:
|
||||
def _with_timeout_metadata(data: dict) -> dict:
|
||||
item = dict(data or {})
|
||||
requested_at = float(item.get("requested_at") or time.time())
|
||||
timeout_seconds = int(item.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
|
||||
expires_at = float(item.get("expires_at") or requested_at + timeout_seconds)
|
||||
raw_timeout = item.get("timeout_seconds")
|
||||
timeout_seconds = int(raw_timeout) if raw_timeout is not None else DEFAULT_TIMEOUT_SECONDS
|
||||
item["requested_at"] = requested_at
|
||||
item["timeout_seconds"] = timeout_seconds
|
||||
item["expires_at"] = expires_at
|
||||
if timeout_seconds <= 0:
|
||||
# Unlimited: no expiry. The frontend renders no countdown and the card
|
||||
# waits until the user answers (or the run is cancelled).
|
||||
item["expires_at"] = 0
|
||||
return item
|
||||
item["expires_at"] = float(item.get("expires_at") or requested_at + timeout_seconds)
|
||||
return item
|
||||
|
||||
|
||||
|
||||
+932
-113
File diff suppressed because it is too large
Load Diff
@@ -268,8 +268,11 @@ def install_crash_visibility(
|
||||
sigusr1 = getattr(signal, "SIGUSR1", None)
|
||||
if sigusr1 is not None:
|
||||
try:
|
||||
# A diagnostic signal must not inherit SIGUSR1's default
|
||||
# terminate action after the dump. Chaining here made a
|
||||
# hang probe kill the server and trigger service restarts.
|
||||
faulthandler.register(
|
||||
sigusr1, file=_FAULT_STREAM, all_threads=True, chain=True
|
||||
sigusr1, file=_FAULT_STREAM, all_threads=True, chain=False
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+30
-2
@@ -1854,7 +1854,7 @@ def install_extension(id: object, download_url: object, sha256: object) -> Dict[
|
||||
ext_dir_resolved = ext_dir.resolve()
|
||||
for member_name in file_members:
|
||||
decoded = _fully_unquote_path(_stripped(member_name))
|
||||
if not decoded or not _is_safe_relative_path(decoded):
|
||||
if not decoded or not _is_safe_archive_member(decoded):
|
||||
raise ExtensionInstallError("Unsafe archive member")
|
||||
resolved = (ext_dir / decoded).resolve()
|
||||
try:
|
||||
@@ -1956,7 +1956,7 @@ def uninstall_extension(id: object) -> Dict[str, Any]:
|
||||
raise ExtensionInstallError("Extension not installed", 404)
|
||||
ext_dir = root / ext_id
|
||||
for rel_path in entry.get("files", []):
|
||||
if not _is_safe_relative_path(rel_path):
|
||||
if not _is_safe_archive_member(rel_path):
|
||||
continue
|
||||
target = (ext_dir / rel_path).resolve()
|
||||
try:
|
||||
@@ -2068,6 +2068,9 @@ def inject_extension_tags(index_html: str) -> str:
|
||||
|
||||
|
||||
def _is_safe_relative_path(rel: str) -> bool:
|
||||
# Strict: reject empty, traversal, AND any dot-prefixed segment. This is shared
|
||||
# by static serving, asset URLs and manifest paths, where a hidden file must
|
||||
# never become reachable. Archive members use _is_safe_archive_member() below.
|
||||
if not rel or "\x00" in rel or "\\" in rel:
|
||||
return False
|
||||
for segment in rel.split("/"):
|
||||
@@ -2076,6 +2079,31 @@ def _is_safe_relative_path(rel: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# Benign hidden LEAF files that extension archives legitimately ship. A dot-prefixed
|
||||
# name is permitted only as the final path segment and only if it is one of these;
|
||||
# dot-directories (e.g. ".git/") and every other dotfile (".env", ".secret") stay rejected.
|
||||
_ALLOWED_ARCHIVE_DOTFILES = frozenset({".gitkeep", ".gitignore", ".gitattributes", ".env.example"})
|
||||
|
||||
|
||||
def _is_safe_archive_member(rel: str) -> bool:
|
||||
"""Path validator for archive install/uninstall ONLY. Same containment as
|
||||
_is_safe_relative_path(), but permits a narrow allowlist of benign hidden leaf
|
||||
files so extensions can ship ".gitkeep"/".env.example" without loosening the
|
||||
shared validator that also gates static serving."""
|
||||
if not rel or "\x00" in rel or "\\" in rel:
|
||||
return False
|
||||
segments = rel.split("/")
|
||||
for segment in segments[:-1]:
|
||||
if not segment or segment in (".", "..") or segment.startswith("."):
|
||||
return False
|
||||
leaf = segments[-1]
|
||||
if not leaf or leaf in (".", ".."):
|
||||
return False
|
||||
if leaf.startswith(".") and leaf not in _ALLOWED_ARCHIVE_DOTFILES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _not_found(handler) -> bool:
|
||||
j(handler, {"error": "not found"}, status=404)
|
||||
return True
|
||||
|
||||
+142
-14
@@ -24,9 +24,12 @@ from api.config import (
|
||||
STREAM_PARTIAL_TEXT,
|
||||
STREAM_REASONING_TEXT,
|
||||
_get_session_agent_lock,
|
||||
_parse_provider_qualified_model_id,
|
||||
clear_session_writeback_owner_if_owned,
|
||||
coerce_reasoning_effort_for_model,
|
||||
gateway_approval_unavailable_reason,
|
||||
gateway_supports_approval,
|
||||
peek_stream,
|
||||
register_active_run,
|
||||
unregister_active_run,
|
||||
unregister_stream_owner,
|
||||
@@ -152,6 +155,28 @@ _WEBUI_GATEWAY_USE_RUNS_API_ENV = "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
|
||||
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
||||
|
||||
|
||||
def _gateway_model_field(model: str | None) -> str:
|
||||
"""Return the bare model name to put in a gateway request body.
|
||||
|
||||
The picker and ``_resolve_compatible_session_model_state`` intentionally
|
||||
keep the full ``@provider:model`` string for internal routing (#1253), but
|
||||
the gateway expects a bare model name and carries the provider separately.
|
||||
Sent verbatim, ``@ollama-cloud:minimax-m2.7`` is forwarded to the upstream
|
||||
provider API, which 404s on the ``@``-prefixed string (#6722).
|
||||
|
||||
Parsing is delegated to ``config._parse_provider_qualified_model_id()`` so
|
||||
a multi-segment custom provider ID (``@custom:backup:model-a``) yields the
|
||||
real model (``model-a``) instead of a positional-split fragment.
|
||||
"""
|
||||
if not model:
|
||||
return ""
|
||||
value = str(model).strip()
|
||||
parsed = _parse_provider_qualified_model_id(value)
|
||||
if parsed:
|
||||
return str(parsed[0] or "").strip()
|
||||
return value
|
||||
|
||||
|
||||
# Total byte-silence budget (seconds) for the gateway SSE socket, applied via
|
||||
# ``urlopen(timeout=...)``. A stream that emits ANY byte within the window never
|
||||
# trips it, so a genuinely alive (if slow) token stream is untouched; only more
|
||||
@@ -303,6 +328,73 @@ def _gateway_reasoning_effort_for_request(cfg, *, model=None, model_provider=Non
|
||||
return None
|
||||
|
||||
|
||||
def _gateway_session_yolo_enabled(session_id: str) -> bool:
|
||||
"""Return the WebUI-owned, in-memory YOLO state for a browser session."""
|
||||
try:
|
||||
from tools.approval import is_session_yolo_enabled
|
||||
|
||||
return bool(is_session_yolo_enabled(str(session_id or "")))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _settle_gateway_run_approval(
|
||||
session_id: str,
|
||||
approval_data: dict,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
) -> tuple[bool, dict | None, int]:
|
||||
"""Auto-approve or mirror one run approval at a session-linearized point."""
|
||||
from api.route_approvals import gateway_yolo_handoff, submit_gateway_pending_mirror
|
||||
|
||||
run_id = str(approval_data.get("run_id") or "").strip()
|
||||
identity_v1 = bool(approval_data.get("_gateway_agent_identity_v1"))
|
||||
with gateway_yolo_handoff(session_id):
|
||||
if _gateway_session_yolo_enabled(session_id):
|
||||
try:
|
||||
_auto_approve_gateway_run(
|
||||
base_url,
|
||||
api_key,
|
||||
run_id,
|
||||
approval_data["approval_id"] if identity_v1 else "",
|
||||
)
|
||||
return True, None, 0
|
||||
except Exception:
|
||||
# Fail closed: if remote approval fails, surface the real card
|
||||
# before allowing a same-session toggle to pass the handoff.
|
||||
logger.warning(
|
||||
"WebUI YOLO could not auto-approve run %s; showing approval card",
|
||||
run_id,
|
||||
exc_info=True,
|
||||
)
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
return False, head, total
|
||||
|
||||
|
||||
def _auto_approve_gateway_run(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
run_id: str,
|
||||
approval_id: str,
|
||||
) -> None:
|
||||
"""Resolve one Runs API prompt using only the shipped approval contract.
|
||||
|
||||
This is a WebUI-owned compatibility path: the Runs API does not yet expose
|
||||
session YOLO, so WebUI answers each approval request while its own session
|
||||
flag is enabled. Native Agent-side YOLO would be preferable because it can
|
||||
bypass gates before they pause and also covers Agent-owned computer-use
|
||||
policy; https://github.com/NousResearch/hermes-agent/pull/61946 tracks that
|
||||
API capability. Until then, do not send speculative fields to the Agent.
|
||||
"""
|
||||
from api.runner_client import HttpRunnerClient
|
||||
|
||||
HttpRunnerClient(base_url=base_url, api_key=api_key).respond_approval(
|
||||
run_id,
|
||||
approval_id,
|
||||
"once",
|
||||
)
|
||||
|
||||
|
||||
def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
|
||||
"""Return redacted Gateway-backed chat configuration status."""
|
||||
mode = webui_chat_backend_mode(config_data, environ)
|
||||
@@ -474,6 +566,7 @@ def _run_gateway_runs_api_streaming(
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
*, put_gateway_event, cancel_event,
|
||||
attachments=None, cfg=None, session=None,
|
||||
active_provider: str = "",
|
||||
):
|
||||
"""Submit via POST /v1/runs and relay SSE events including approval."""
|
||||
try:
|
||||
@@ -491,7 +584,7 @@ def _run_gateway_runs_api_streaming(
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider, profile=getattr(session, "profile", None))
|
||||
except Exception:
|
||||
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
@@ -529,7 +622,7 @@ def _run_gateway_runs_api_streaming(
|
||||
if isinstance(run_input, list):
|
||||
run_input = [{"role": "user", "content": run_input}]
|
||||
run_body = {
|
||||
"model": model or "default",
|
||||
"model": _gateway_model_field(model) or "default",
|
||||
"input": run_input,
|
||||
**body_extras,
|
||||
"session_id": session_id,
|
||||
@@ -590,9 +683,17 @@ def _run_gateway_runs_api_streaming(
|
||||
if approval_data:
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
approval_data["_gateway_agent_identity_v1"] = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
from api.route_approvals import submit_gateway_pending_mirror
|
||||
head, total = submit_gateway_pending_mirror(session_id, approval_data)
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if auto_approved:
|
||||
sse_event = "message"
|
||||
continue
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
sse_event = "message"
|
||||
continue
|
||||
@@ -757,14 +858,19 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
session.workspace = str(workspace)
|
||||
session.model = model
|
||||
session.model_provider = model_provider
|
||||
terminal_session_persisted = False
|
||||
try:
|
||||
session.save()
|
||||
terminal_session_persisted = True
|
||||
except Exception:
|
||||
logger.debug("Failed to persist gateway terminal error settlement", exc_info=True)
|
||||
error_payload["session"] = redact_session_data(
|
||||
_session_payload_with_full_messages(session, tool_calls=[])
|
||||
)
|
||||
error_payload["session_id"] = session.session_id
|
||||
error_payload["terminal_session_persisted"] = terminal_session_persisted
|
||||
if terminal_session_persisted:
|
||||
error_payload["terminal_session_persisted_session_id"] = session.session_id
|
||||
return error_payload
|
||||
|
||||
|
||||
@@ -804,6 +910,7 @@ def _run_gateway_chat_streaming(
|
||||
*,
|
||||
model_provider=None,
|
||||
goal_related=False,
|
||||
regeneration=False,
|
||||
):
|
||||
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
|
||||
|
||||
@@ -813,13 +920,17 @@ def _run_gateway_chat_streaming(
|
||||
the configured Gateway API server into those local events and persists the
|
||||
final user/assistant turn back into the WebUI session.
|
||||
"""
|
||||
q = STREAMS.get(stream_id)
|
||||
q = peek_stream(stream_id)
|
||||
if q is None:
|
||||
_finish_gateway_run_starting(stream_id, result="fallback")
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
# Cancelled before the worker started; release the owner entry the route
|
||||
# layer registered so STREAM_SESSION_OWNERS does not leak (no teardown finally runs).
|
||||
unregister_stream_owner(stream_id)
|
||||
# Also release the writeback-owner entry the route layer registered, so
|
||||
# SESSION_WRITEBACK_OWNERS does not leak on this pre-start cancellation
|
||||
# path (the teardown finally below never runs when we early-return here).
|
||||
clear_session_writeback_owner_if_owned(session_id, stream_id)
|
||||
return
|
||||
register_active_run(
|
||||
stream_id,
|
||||
@@ -957,6 +1068,7 @@ def _run_gateway_chat_streaming(
|
||||
attachments=attachments,
|
||||
cfg=cfg,
|
||||
session=s,
|
||||
active_provider=(model_provider or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
error_payload = _settle_gateway_terminal_error(
|
||||
@@ -1008,12 +1120,12 @@ def _run_gateway_chat_streaming(
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg)
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or ""), profile=getattr(s, "profile", None))
|
||||
except Exception:
|
||||
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
body = {
|
||||
"model": model or "default",
|
||||
"model": _gateway_model_field(model) or "default",
|
||||
"stream": True,
|
||||
"messages": [*prefill_messages, {"role": "user", "content": message_content}],
|
||||
}
|
||||
@@ -1169,18 +1281,29 @@ def _run_gateway_chat_streaming(
|
||||
# same sort key; later transcript merges can then fall back to
|
||||
# role/content ordering instead of turn order.
|
||||
assistant_ts = now + 0.000001
|
||||
user_msg = {"role": "user", "content": str(msg_text or ""), "timestamp": now}
|
||||
pending_source = getattr(s, "pending_user_source", None) or "webui"
|
||||
if pending_source != "webui":
|
||||
user_msg["_source"] = pending_source
|
||||
if attachments:
|
||||
user_msg["attachments"] = list(attachments)
|
||||
from api.streaming import _active_turn_authority, _materialize_active_turn_user
|
||||
|
||||
active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
|
||||
user_msg = _materialize_active_turn_user(
|
||||
active_turn_identity,
|
||||
str(msg_text or ""),
|
||||
pending_source,
|
||||
)
|
||||
user_msg["timestamp"] = float(
|
||||
active_turn_identity.get("timestamp") or now
|
||||
)
|
||||
assistant_msg = {"role": "assistant", "content": assistant_text, "timestamp": assistant_ts}
|
||||
saved_reasoning = STREAM_REASONING_TEXT.get(stream_id, "")
|
||||
if saved_reasoning:
|
||||
assistant_msg["reasoning"] = saved_reasoning
|
||||
previous_messages = list(getattr(s, "messages", None) or [])
|
||||
previous_context = list(getattr(s, "context_messages", None) or getattr(s, "messages", None) or [])
|
||||
stored_context = getattr(s, "context_messages", None)
|
||||
previous_context = list(
|
||||
stored_context
|
||||
if isinstance(stored_context, list) and (regeneration or stored_context)
|
||||
else getattr(s, "messages", None) or []
|
||||
)
|
||||
previous_process_wakeup_pause = dict(getattr(s, "process_wakeup_pause", {}) or {})
|
||||
# Stamp stable ids on the two new rows (shared with the display merge
|
||||
# below) so display and model-context copies share an id for the
|
||||
@@ -1366,3 +1489,8 @@ def _run_gateway_chat_streaming(
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
unregister_stream_owner(stream_id)
|
||||
unregister_active_run(stream_id)
|
||||
# Release the writeback-owner entry the route layer registered for this
|
||||
# Gateway run so SESSION_WRITEBACK_OWNERS does not grow unbounded across
|
||||
# the process lifetime (compare-and-clear: only clears if still owned by
|
||||
# this stream, mirroring the local streaming teardown).
|
||||
clear_session_writeback_owner_if_owned(session_id, stream_id)
|
||||
|
||||
+71
-57
@@ -9,7 +9,6 @@ This enables real-time session list updates in the sidebar without
|
||||
requiring any changes to hermes-agent.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
@@ -44,7 +43,7 @@ _WATCHER_EXCLUDED_SOURCES = ("cron", "webui")
|
||||
|
||||
|
||||
def _cheap_change_fingerprint(db_path: Path) -> str | None:
|
||||
"""Compute a cheap change-detection fingerprint without the messages JOIN.
|
||||
"""Compute a cheap fingerprint with an index-covered message aggregate.
|
||||
|
||||
The expensive projection (``read_importable_agent_session_rows``) runs a CTE
|
||||
plus a per-session ``MAX(messages.timestamp)`` aggregation over an oversampled
|
||||
@@ -53,25 +52,15 @@ def _cheap_change_fingerprint(db_path: Path) -> str | None:
|
||||
scan, and the watcher runs it forever on a 5s timer even when nothing changed
|
||||
(issue #3506).
|
||||
|
||||
This computes a fingerprint from a ``sessions``-table-only scan (no messages
|
||||
JOIN), scoped to the same non-cron/webui rows as the projection. To guarantee
|
||||
it never skips a change the projection would reflect, it hashes **every
|
||||
sessions-table column the projection reads or uses for visibility/collapse**
|
||||
-- not just the columns surfaced to the sidebar. That matters because the
|
||||
projection collapses compression lineage and hides/shows rows based on
|
||||
``parent_session_id`` / ``ended_at`` / ``end_reason`` / ``source``, so a change
|
||||
to one of those alters *which rows* appear even when no displayed field on a
|
||||
given row moved.
|
||||
This hashes every sessions-table column the projection uses, plus a
|
||||
per-session ``COUNT`` / ``MAX(messages.timestamp)`` aggregate scoped to the
|
||||
same non-cron/webui rows. The message aggregate stays on the agent's existing
|
||||
``(session_id, timestamp)`` covering index, avoiding a table-page lookup for
|
||||
every historical message.
|
||||
|
||||
The one projection input that does not live in the ``sessions`` table is the
|
||||
per-session message aggregate (``COUNT`` / ``MAX(messages.timestamp)`` ->
|
||||
``last_activity``). That is fully proxied by ``sessions.message_count``: the
|
||||
agent's state layer bumps ``message_count`` on every appended message and
|
||||
rewrites it to the absolute count on truncate/rewind/compaction, so a message
|
||||
insert or delete (the only events that can move ``MAX(timestamp)``) always
|
||||
changes ``message_count``. The fingerprint is therefore a strict superset of
|
||||
the projection's change surface (it also fires on out-of-order inserts that
|
||||
would not raise ``MAX(timestamp)``).
|
||||
``role`` is intentionally absent because it is not in that index. A bounded
|
||||
periodic full projection in ``GatewayWatcher._poll_once`` covers rare
|
||||
role-only visibility mutations without restoring the five-second table scan.
|
||||
|
||||
Returns the fingerprint string, or ``None`` on any error / a pre-source
|
||||
schema so the caller falls back to running the expensive projection rather
|
||||
@@ -110,21 +99,17 @@ def _cheap_change_fingerprint(db_path: Path) -> str | None:
|
||||
# timestamps but can leave sessions.message_count unchanged — so the
|
||||
# sessions-only scan above would miss it and the watcher would skip a
|
||||
# projection whose last_activity (MAX(messages.timestamp)) actually
|
||||
# moved. Fold in a PER-SESSION message aggregate, scoped to the same
|
||||
# non-excluded sessions as the projection. It must be per-session
|
||||
# (grouped), NOT a single global MAX: rewriting an OLDER, non-newest
|
||||
# session moves that session's last_activity but not the global max,
|
||||
# so a global aggregate would still miss it (#3536 review round 2).
|
||||
# cron/webui churn is excluded by the JOIN filter so it still does
|
||||
# NOT trigger a re-projection. This is one GROUP BY over the already-
|
||||
# filtered set — far cheaper than the projection's oversampled
|
||||
# correlated CTE — so it preserves the cheap-fingerprint property.
|
||||
# moved. Fold in a PER-SESSION COUNT/MAX aggregate, scoped to the same
|
||||
# non-excluded sessions as the projection. COUNT preserves drift
|
||||
# detection; MAX catches same-count rewrites because replacement rows
|
||||
# receive fresh timestamps. Do not read ``role`` here: the normal
|
||||
# (session_id, timestamp) index can then cover this five-second scan
|
||||
# instead of forcing a table-page lookup for every historical row.
|
||||
if 'messages' in {r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}:
|
||||
try:
|
||||
msg_rows = conn.execute(
|
||||
"SELECT s.id, COUNT(m.id), "
|
||||
"COUNT(CASE WHEN LOWER(m.role) = 'user' THEN 1 END), "
|
||||
"COALESCE(MAX(m.timestamp), 0) "
|
||||
"FROM sessions s LEFT JOIN messages m ON m.session_id = s.id "
|
||||
f"WHERE s.source IS NOT NULL AND s.source NOT IN ({placeholders}) "
|
||||
@@ -157,9 +142,11 @@ def _get_state_db_path(hermes_home: Path | None = None) -> Path:
|
||||
return hermes_home / 'state.db'
|
||||
|
||||
|
||||
def _get_agent_sessions_from_db(db_path: Path | None = None) -> list:
|
||||
def _get_agent_sessions_from_db(db_path: Path | None = None) -> list | None:
|
||||
"""Read all non-webui sessions from state.db.
|
||||
Returns list of session dicts, or empty list on any error.
|
||||
|
||||
Returns a list of session dicts (including an empty list for a successful
|
||||
empty projection), or ``None`` when the projection fails.
|
||||
"""
|
||||
db_path = Path(db_path) if db_path is not None else _get_state_db_path()
|
||||
if not db_path.exists():
|
||||
@@ -182,7 +169,7 @@ def _get_agent_sessions_from_db(db_path: Path | None = None) -> list:
|
||||
})
|
||||
return sessions
|
||||
except Exception:
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
# ── GatewayWatcher ──────────────────────────────────────────────────────────
|
||||
@@ -200,6 +187,11 @@ class GatewayWatcher:
|
||||
"""
|
||||
|
||||
POLL_INTERVAL = 5 # seconds between polls
|
||||
# ``messages.role`` is not present in the agent's covering
|
||||
# ``(session_id, timestamp)`` index, but the full projection uses it for CLI
|
||||
# visibility. Keep the hot poll index-only and bound detection of rare
|
||||
# role-only mutations with a periodic parity projection.
|
||||
PROJECTION_PARITY_INTERVAL = 60.0
|
||||
SUBSCRIBER_TIMEOUT = 30 # seconds before sending keepalive comment
|
||||
|
||||
def __init__(
|
||||
@@ -226,6 +218,7 @@ class GatewayWatcher:
|
||||
# unchanged we skip the expensive messages-JOIN projection entirely
|
||||
# (issue #3506). Empty string forces the first poll to run the full read.
|
||||
self._last_cheap_fp: str = ''
|
||||
self._last_full_projection_at: float | None = None
|
||||
|
||||
def start(self):
|
||||
"""Start the watcher daemon thread."""
|
||||
@@ -317,34 +310,55 @@ class GatewayWatcher:
|
||||
except Exception:
|
||||
logger.debug("Failed to send sentinel to dead subscriber")
|
||||
|
||||
def _poll_once(self, *, now: float | None = None) -> bool:
|
||||
"""Run one change-detection pass and report whether projection ran.
|
||||
|
||||
Most passes stay on the covering fingerprint. A bounded parity pass
|
||||
protects projection fields (notably role-derived CLI visibility) that
|
||||
the agent's existing index cannot see.
|
||||
"""
|
||||
db_path = self._state_db_path
|
||||
# A watcher may start before the agent has created state.db. Publishing an
|
||||
# empty first snapshot would make an already-rendered sidebar disappear;
|
||||
# wait for the first real database instead. If a previously observed DB
|
||||
# disappears, the normal projection path still publishes that change.
|
||||
if (
|
||||
not db_path.exists()
|
||||
and self._last_full_projection_at is None
|
||||
and not self._last_hash
|
||||
):
|
||||
return False
|
||||
|
||||
cheap_fp = _cheap_change_fingerprint(db_path) if db_path.exists() else ''
|
||||
current_time = time.monotonic() if now is None else now
|
||||
fingerprint_changed = cheap_fp is None or cheap_fp != self._last_cheap_fp
|
||||
parity_due = (
|
||||
self._last_full_projection_at is None
|
||||
or current_time - self._last_full_projection_at
|
||||
>= self.PROJECTION_PARITY_INTERVAL
|
||||
)
|
||||
if not fingerprint_changed and not parity_due:
|
||||
return False
|
||||
|
||||
sessions = _get_agent_sessions_from_db(db_path)
|
||||
if sessions is None:
|
||||
return False
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
if cheap_fp is not None:
|
||||
self._last_cheap_fp = cheap_fp
|
||||
self._last_full_projection_at = current_time
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
return True
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop. Runs in a daemon thread."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Phase 1: cheap sessions-only fingerprint. The expensive
|
||||
# messages-JOIN projection (_get_agent_sessions_from_db) only
|
||||
# runs when this fingerprint actually changes, so an idle server
|
||||
# with a large state.db stops re-aggregating tens of thousands
|
||||
# of message rows every 5 seconds (issue #3506). A None
|
||||
# fingerprint (error / unreadable db) forces the full read so we
|
||||
# never silently skip a real change.
|
||||
db_path = self._state_db_path
|
||||
cheap_fp = _cheap_change_fingerprint(db_path) if db_path.exists() else ''
|
||||
if cheap_fp is not None and cheap_fp == self._last_cheap_fp:
|
||||
# Nothing changed in the sidebar-visible session set; skip
|
||||
# the expensive projection and the notify entirely.
|
||||
pass
|
||||
else:
|
||||
# Phase 2: only now pay for the full projection.
|
||||
sessions = _get_agent_sessions_from_db(db_path)
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
if cheap_fp is not None:
|
||||
self._last_cheap_fp = cheap_fp
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
self._poll_once()
|
||||
except Exception:
|
||||
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
||||
|
||||
|
||||
+105
-3
@@ -73,8 +73,99 @@ def _profile_db(profile_home: str | Path):
|
||||
return db
|
||||
|
||||
|
||||
def _profile_home_context_api():
|
||||
"""Return the native context-local Hermes home setters when available."""
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
except Exception: # pragma: no cover - depends on installed hermes-agent
|
||||
return None
|
||||
return set_hermes_home_override, reset_hermes_home_override
|
||||
|
||||
|
||||
def _native_profile_context_api(profile_home: str | Path):
|
||||
"""Return context setters only when SessionDB resolves that context at call time."""
|
||||
context_api = _profile_home_context_api()
|
||||
if context_api is None:
|
||||
return None
|
||||
try:
|
||||
from hermes_state import _default_db_path # type: ignore
|
||||
except Exception: # pragma: no cover - depends on installed hermes-agent
|
||||
return None
|
||||
if not callable(_default_db_path):
|
||||
return None
|
||||
|
||||
home = Path(profile_home).expanduser().resolve()
|
||||
set_home, reset_home = context_api
|
||||
try:
|
||||
token = set_home(home)
|
||||
except Exception: # pragma: no cover - depends on installed hermes-agent
|
||||
return None
|
||||
try:
|
||||
resolved_db_path = Path(_default_db_path()).expanduser().resolve()
|
||||
except Exception: # pragma: no cover - depends on installed hermes-agent
|
||||
return None
|
||||
finally:
|
||||
reset_home(token)
|
||||
if resolved_db_path != home / "state.db":
|
||||
return None
|
||||
return context_api
|
||||
|
||||
|
||||
class _ProfileGoalManager:
|
||||
"""Small WebUI-local GoalManager adapter with explicit profile persistence."""
|
||||
"""Run the native GoalManager under a context-local profile home."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
profile_home: str | Path,
|
||||
default_max_turns: int = 20,
|
||||
context_api=None,
|
||||
):
|
||||
if _NativeGoalManager is None:
|
||||
raise RuntimeError("Hermes goal manager unavailable")
|
||||
context_api = context_api or _profile_home_context_api()
|
||||
if context_api is None:
|
||||
raise RuntimeError("Hermes profile context unavailable")
|
||||
self.session_id = session_id
|
||||
self.profile_home = Path(profile_home).expanduser().resolve()
|
||||
self._set_home, self._reset_home = context_api
|
||||
self._manager = self._scoped(
|
||||
_NativeGoalManager,
|
||||
session_id=session_id,
|
||||
default_max_turns=default_max_turns,
|
||||
)
|
||||
|
||||
def _scoped(self, func, *args, **kwargs):
|
||||
token = self._set_home(self.profile_home)
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
finally:
|
||||
self._reset_home(token)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self._manager.state
|
||||
|
||||
def __getattr__(self, name):
|
||||
value = getattr(self._manager, name)
|
||||
if not callable(value):
|
||||
return value
|
||||
|
||||
def scoped_call(*args, **kwargs):
|
||||
return self._scoped(value, *args, **kwargs)
|
||||
|
||||
return scoped_call
|
||||
|
||||
def _restore_state(self, snapshot) -> None:
|
||||
from hermes_cli.goals import save_goal # type: ignore
|
||||
|
||||
self._manager._state = snapshot
|
||||
self._scoped(save_goal, self.session_id, snapshot)
|
||||
|
||||
|
||||
class _LegacyProfileGoalManager:
|
||||
"""Explicit-DB fallback for Hermes versions without profile context."""
|
||||
|
||||
def __init__(self, session_id: str, *, profile_home: str | Path, default_max_turns: int = 20):
|
||||
if GoalState is None:
|
||||
@@ -193,7 +284,7 @@ class _ProfileGoalManager:
|
||||
if judge_goal is None:
|
||||
verdict, reason = "continue", "goal judge unavailable"
|
||||
else:
|
||||
verdict, reason = judge_goal(state.goal, str(last_response or ""))
|
||||
verdict, reason, *_ = judge_goal(state.goal, str(last_response or ""))
|
||||
state.last_verdict = verdict
|
||||
state.last_reason = reason
|
||||
|
||||
@@ -246,7 +337,15 @@ def _manager(session_id: str, *, profile_home: str | Path | None = None):
|
||||
return None
|
||||
if profile_home and GoalManager is _NativeGoalManager and GoalState is not None:
|
||||
try:
|
||||
return _ProfileGoalManager(
|
||||
context_api = _native_profile_context_api(profile_home)
|
||||
if context_api is not None:
|
||||
return _ProfileGoalManager(
|
||||
session_id=session_id,
|
||||
profile_home=profile_home,
|
||||
default_max_turns=_default_max_turns(),
|
||||
context_api=context_api,
|
||||
)
|
||||
return _LegacyProfileGoalManager(
|
||||
session_id=session_id,
|
||||
profile_home=profile_home,
|
||||
default_max_turns=_default_max_turns(),
|
||||
@@ -407,6 +506,9 @@ def restore_goal_state(session_id: str, snapshot: Any, *, profile_home: str | Pa
|
||||
pass
|
||||
return
|
||||
if isinstance(mgr, _ProfileGoalManager):
|
||||
mgr._restore_state(snapshot)
|
||||
return
|
||||
if isinstance(mgr, _LegacyProfileGoalManager):
|
||||
mgr._state = snapshot
|
||||
mgr._save(snapshot)
|
||||
return
|
||||
|
||||
+264
-51
@@ -14,6 +14,17 @@ from api.config import IMAGE_EXTS, MD_EXTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
|
||||
"api_content",
|
||||
"_row_id",
|
||||
"_state_db_row_id",
|
||||
"_db_row_id",
|
||||
"state_db_row_id",
|
||||
"_active_turn_token",
|
||||
"_active_turn_user",
|
||||
"_fork_child_turn",
|
||||
})
|
||||
|
||||
|
||||
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
|
||||
# connections often end this way when a browser tab sleeps, a phone switches
|
||||
@@ -949,10 +960,7 @@ def _redact_value(v, *, _enabled: bool | None = None):
|
||||
if isinstance(v, str):
|
||||
return _redact_text(v, _enabled=_enabled)
|
||||
if isinstance(v, dict):
|
||||
return {
|
||||
key: _redact_value(value, _enabled=_enabled)
|
||||
for key, value in v.items()
|
||||
}
|
||||
return {key: _redact_value(value, _enabled=_enabled) for key, value in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [_redact_value(item, _enabled=_enabled) for item in v]
|
||||
return v
|
||||
@@ -985,57 +993,254 @@ def _redact_message_content_part(part, *, _enabled: bool):
|
||||
return result
|
||||
|
||||
|
||||
def _redact_messages(messages, *, _enabled: bool):
|
||||
def _scrub_alias_record(record):
|
||||
"""Copy one schema record while removing private replay aliases.
|
||||
|
||||
This deliberately copies only one record level. Values such as a tool's
|
||||
``args`` or a function's opaque ``arguments`` are business payloads, not
|
||||
nested WebUI records, so parsing or recursively walking them would corrupt
|
||||
valid user data.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
return _copy_json_value(record)
|
||||
return {
|
||||
key: _copy_json_value(value)
|
||||
for key, value in record.items()
|
||||
if key not in _PUBLIC_MESSAGE_INTERNAL_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def _scrub_content_part(part):
|
||||
"""Scrub one canonical ``messages[*].content[*]`` part."""
|
||||
return _scrub_alias_record(part)
|
||||
|
||||
|
||||
def _scrub_function_record(function):
|
||||
"""Scrub a tool-call function envelope without parsing arguments."""
|
||||
return _scrub_alias_record(function)
|
||||
|
||||
|
||||
def _scrub_tool_call_record(tool_call):
|
||||
"""Scrub one canonical tool-call record and its function envelope."""
|
||||
result = _scrub_alias_record(tool_call)
|
||||
if not isinstance(tool_call, dict):
|
||||
return result
|
||||
function = tool_call.get("function")
|
||||
if isinstance(function, dict):
|
||||
result["function"] = _scrub_function_record(function)
|
||||
return result
|
||||
|
||||
|
||||
def _scrub_message_record(message, *, preserve_api_content: bool = False):
|
||||
"""Scrub one message record along its authoritative nested schema paths."""
|
||||
if not isinstance(message, dict):
|
||||
return _copy_json_value(message)
|
||||
result = _scrub_alias_record(message)
|
||||
if preserve_api_content and isinstance(message.get("api_content"), str) and message.get("api_content"):
|
||||
result["api_content"] = message["api_content"]
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
result["content"] = [_scrub_content_part(part) for part in content]
|
||||
tool_calls = message.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
result["tool_calls"] = [_scrub_tool_call_record(call) for call in tool_calls]
|
||||
return result
|
||||
|
||||
|
||||
def _scrub_message_records(messages, *, preserve_api_content: bool = False):
|
||||
if not isinstance(messages, list):
|
||||
return _copy_json_value(messages)
|
||||
return [
|
||||
_scrub_message_record(message, preserve_api_content=preserve_api_content)
|
||||
for message in messages
|
||||
]
|
||||
|
||||
|
||||
def _scrub_tool_call_records(tool_calls):
|
||||
if not isinstance(tool_calls, list):
|
||||
return _copy_json_value(tool_calls)
|
||||
return [_scrub_tool_call_record(call) for call in tool_calls]
|
||||
|
||||
|
||||
def _scrub_runtime_journal_snapshot(snapshot):
|
||||
"""Scrub only the snapshot's real message/tool-call arrays.
|
||||
|
||||
A live tool's ``args`` dictionary is intentionally opaque. In particular,
|
||||
``tool_calls[].args.messages[]`` is ordinary business input even though the
|
||||
field name happens to be ``messages``; it is not a transcript container.
|
||||
"""
|
||||
if not isinstance(snapshot, dict):
|
||||
return _copy_json_value(snapshot)
|
||||
result = _copy_json_value(snapshot)
|
||||
if isinstance(snapshot.get("messages"), list):
|
||||
result["messages"] = _scrub_message_records(snapshot["messages"])
|
||||
if isinstance(snapshot.get("tool_calls"), list):
|
||||
result["tool_calls"] = _scrub_tool_call_records(snapshot["tool_calls"])
|
||||
return result
|
||||
|
||||
|
||||
def scrub_internal_replay_fields(
|
||||
value,
|
||||
*,
|
||||
preserve_message_api_content: bool = False,
|
||||
message_records: bool | None = None,
|
||||
):
|
||||
"""Copy runtime data and scrub aliases at authoritative schema positions.
|
||||
|
||||
``message_records`` selects the shape of a bare list (messages versus
|
||||
session-level tool calls). A session-shaped dictionary follows only its
|
||||
named ``messages``, ``context_messages``, ``tool_calls``, and
|
||||
``runtime_journal_snapshot`` fields. No generic key-name recursion is
|
||||
performed, so arbitrary nested tool arguments remain byte-for-byte intact.
|
||||
The Agent boundary may retain a message record's trusted ``api_content``;
|
||||
public/import boundaries use the default, stripping it everywhere.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
if message_records is False:
|
||||
return _scrub_tool_call_records(value)
|
||||
return _scrub_message_records(
|
||||
value,
|
||||
preserve_api_content=preserve_message_api_content,
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
return _copy_json_value(value)
|
||||
result = _copy_json_value(value)
|
||||
for key, child in value.items():
|
||||
if key in {"messages", "context_messages"} and isinstance(child, list):
|
||||
result[key] = _scrub_message_records(
|
||||
child,
|
||||
preserve_api_content=preserve_message_api_content,
|
||||
)
|
||||
elif key == "tool_calls" and isinstance(child, list):
|
||||
result[key] = _scrub_tool_call_records(child)
|
||||
elif key == "runtime_journal_snapshot" and isinstance(child, dict):
|
||||
result[key] = _scrub_runtime_journal_snapshot(child)
|
||||
return result
|
||||
|
||||
|
||||
def _public_message_projection(message, *, _enabled: bool, _active_turn_token=None):
|
||||
"""Return one public transcript message without internal replay fields."""
|
||||
is_active = (
|
||||
isinstance(message, dict)
|
||||
and message.get("role") == "user"
|
||||
and _active_turn_token is not None
|
||||
and message.get("_active_turn_token") == _active_turn_token
|
||||
)
|
||||
message = scrub_internal_replay_fields([message], message_records=True)[0]
|
||||
if not isinstance(message, dict):
|
||||
return _redact_value(message, _enabled=_enabled)
|
||||
item = {}
|
||||
allow_native_image = message.get("role") == "user"
|
||||
for key, value in message.items():
|
||||
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
|
||||
continue
|
||||
if allow_native_image and key == "content" and isinstance(value, list):
|
||||
item[key] = [
|
||||
_redact_message_content_part(part, _enabled=_enabled)
|
||||
for part in value
|
||||
]
|
||||
else:
|
||||
item[key] = _redact_value(value, _enabled=_enabled)
|
||||
if is_active:
|
||||
item["_active_turn_user"] = True
|
||||
return item
|
||||
|
||||
|
||||
def _redact_messages(messages, *, _enabled: bool, _active_turn_token=None):
|
||||
if not isinstance(messages, list):
|
||||
return _redact_value(messages, _enabled=_enabled)
|
||||
redacted = []
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
redacted.append(_redact_value(message, _enabled=_enabled))
|
||||
continue
|
||||
item = {}
|
||||
allow_native_image = message.get("role") == "user"
|
||||
for key, value in message.items():
|
||||
if allow_native_image and key == "content" and isinstance(value, list):
|
||||
item[key] = [
|
||||
_redact_message_content_part(part, _enabled=_enabled)
|
||||
for part in value
|
||||
]
|
||||
else:
|
||||
item[key] = _redact_value(value, _enabled=_enabled)
|
||||
redacted.append(item)
|
||||
return redacted
|
||||
return [
|
||||
_public_message_projection(
|
||||
message,
|
||||
_enabled=_enabled,
|
||||
_active_turn_token=_active_turn_token,
|
||||
)
|
||||
for message in messages
|
||||
]
|
||||
|
||||
|
||||
def _redact_tool_calls(tool_calls, *, _enabled: bool):
|
||||
scrubbed = scrub_internal_replay_fields(tool_calls, message_records=False)
|
||||
return _redact_value(scrubbed, _enabled=_enabled)
|
||||
|
||||
|
||||
def _redact_nested_message_containers(value, *, _enabled: bool):
|
||||
"""Redact only the runtime snapshot's authoritative message arrays."""
|
||||
scrubbed = scrub_internal_replay_fields(value)
|
||||
if not isinstance(scrubbed, dict):
|
||||
return _redact_value(scrubbed, _enabled=_enabled)
|
||||
result = {}
|
||||
for key, child in scrubbed.items():
|
||||
if key in {"messages", "context_messages"} and isinstance(child, list):
|
||||
result[key] = _redact_messages(child, _enabled=_enabled)
|
||||
elif key == "tool_calls" and isinstance(child, list):
|
||||
result[key] = _redact_tool_calls(child, _enabled=_enabled)
|
||||
elif key == "runtime_journal_snapshot" and isinstance(child, dict):
|
||||
result[key] = _redact_nested_message_containers(child, _enabled=_enabled)
|
||||
else:
|
||||
result[key] = _redact_value(child, _enabled=_enabled)
|
||||
return result
|
||||
|
||||
|
||||
def public_session_projection(session_dict: dict) -> dict:
|
||||
"""Return a public session payload with redaction and alias stripping.
|
||||
|
||||
Callers use this for every response/export/SSE session payload. It never
|
||||
mutates the in-memory session or the caller's dictionary.
|
||||
"""
|
||||
return redact_session_data(session_dict)
|
||||
|
||||
|
||||
def strip_public_internal_fields(value, *, message_records: bool = False):
|
||||
"""Deep-copy imported records through the shared schema scrubber.
|
||||
|
||||
JSON import uses this before constructing or saving a ``Session``. The
|
||||
Five replay aliases belong to a message/content-part/tool-call/function
|
||||
record itself; matching names inside user content or tool arguments are
|
||||
ordinary JSON and must be preserved. This is intentionally independent of
|
||||
the credential-redaction setting: caller-supplied provider sidecars must
|
||||
never become durable WebUI session state.
|
||||
"""
|
||||
return scrub_internal_replay_fields(value, message_records=message_records)
|
||||
|
||||
|
||||
def _copy_json_value(value):
|
||||
"""Deep-copy JSON-shaped data without applying message-field filtering."""
|
||||
if isinstance(value, dict):
|
||||
return {key: _copy_json_value(child) for key, child in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_copy_json_value(child) for child in value]
|
||||
return value
|
||||
|
||||
|
||||
def redact_session_data(session_dict: dict) -> dict:
|
||||
"""Redact credentials from message content, tool data, and session sidecars.
|
||||
|
||||
Applies to: messages[], tool_calls[], todo_state, runtime_journal_snapshot,
|
||||
and title.
|
||||
The underlying session file is not modified; redaction is response-layer only.
|
||||
|
||||
Reads the ``api_redact_enabled`` setting ONCE for the entire response and
|
||||
threads it through to avoid hundreds of settings.json reads per session
|
||||
payload (a 50-message session has hundreds of nested strings). When the
|
||||
setting is disabled this is also a fast path: the recursion still walks
|
||||
but every string returns early.
|
||||
"""
|
||||
"""Redact credentials in the public session response without mutation."""
|
||||
from api.config import load_settings
|
||||
_enabled = bool(load_settings().get("api_redact_enabled", True))
|
||||
result = dict(session_dict)
|
||||
if isinstance(result.get('title'), str):
|
||||
result['title'] = _redact_text(result['title'], _enabled=_enabled)
|
||||
if 'messages' in result:
|
||||
result['messages'] = _redact_messages(result['messages'], _enabled=_enabled)
|
||||
if 'tool_calls' in result:
|
||||
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
|
||||
if 'todo_state' in result:
|
||||
result['todo_state'] = _redact_value(result['todo_state'], _enabled=_enabled)
|
||||
if 'runtime_journal_snapshot' in result:
|
||||
result['runtime_journal_snapshot'] = _redact_value(
|
||||
result['runtime_journal_snapshot'],
|
||||
_enabled=_enabled,
|
||||
)
|
||||
if not isinstance(session_dict, dict):
|
||||
return {}
|
||||
result = {}
|
||||
from api.process_event_utils import build_active_turn_token
|
||||
_active_turn_token = build_active_turn_token(session_dict.get("active_stream_id"), session_dict.get("pending_started_at"))
|
||||
for key, value in session_dict.items():
|
||||
if key in _PUBLIC_MESSAGE_INTERNAL_FIELDS:
|
||||
continue
|
||||
if key == 'title' and isinstance(value, str):
|
||||
result[key] = _redact_text(value, _enabled=_enabled)
|
||||
elif key in {'messages', 'context_messages'}:
|
||||
result[key] = _redact_messages(value, _enabled=_enabled, _active_turn_token=_active_turn_token)
|
||||
elif key == 'tool_calls' and isinstance(value, list):
|
||||
result[key] = _redact_tool_calls(value, _enabled=_enabled)
|
||||
elif key in {'todo_state', 'runtime_journal_snapshot'}:
|
||||
result[key] = _redact_nested_message_containers(value, _enabled=_enabled)
|
||||
else:
|
||||
# Operational fields (workspace path, ids, config, timestamps, etc.)
|
||||
# are NOT credential-masked: a valid workspace path may legitimately
|
||||
# contain a credential-shaped component, and masking it would corrupt
|
||||
# the authoritative value the client echoes back on the next send.
|
||||
# Deep-copy them through unchanged (only transcript-bearing fields
|
||||
# above carry free-form user/model text worth redacting).
|
||||
result[key] = _copy_json_value(value)
|
||||
return result
|
||||
|
||||
|
||||
@@ -1063,10 +1268,18 @@ def read_body(handler) -> dict:
|
||||
pass
|
||||
raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})')
|
||||
raw = handler.rfile.read(length) if length else b'{}'
|
||||
try:
|
||||
return _json.loads(raw)
|
||||
except Exception:
|
||||
# A body-optional endpoint (e.g. DELETE /api/mcp/servers/{name}) may send a
|
||||
# whitespace-only body; treat it like an empty body ({}) rather than a 400,
|
||||
# matching the pre-#7336 lenient behavior for that shape (see gate finding).
|
||||
if not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
parsed = _json.loads(raw)
|
||||
except Exception:
|
||||
raise ValueError('Invalid JSON body') from None
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError('JSON body must be an object')
|
||||
return parsed
|
||||
|
||||
|
||||
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
|
||||
|
||||
@@ -20,6 +20,7 @@ from dataclasses import asdict, is_dataclass
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from api.helpers import bad, j
|
||||
from api.workspace import resolve_trusted_workspace
|
||||
|
||||
BOARD_COLUMNS = ["triage", "todo", "ready", "running", "blocked", "done"]
|
||||
_TASK_PREFIX = "/api/kanban/tasks/"
|
||||
@@ -855,6 +856,13 @@ def _create_board_payload(body):
|
||||
slug = str(body.get("slug") or "").strip()
|
||||
if not slug:
|
||||
raise ValueError("slug is required")
|
||||
board_kwargs = {}
|
||||
if "default_workdir" in body:
|
||||
raw_workdir = str(body.get("default_workdir") or "").strip()
|
||||
if raw_workdir:
|
||||
board_kwargs["default_workdir"] = str(resolve_trusted_workspace(raw_workdir))
|
||||
else:
|
||||
board_kwargs["default_workdir"] = ""
|
||||
try:
|
||||
meta = kb.create_board(
|
||||
slug,
|
||||
@@ -862,6 +870,7 @@ def _create_board_payload(body):
|
||||
description=body.get("description") or None,
|
||||
icon=body.get("icon") or None,
|
||||
color=body.get("color") or None,
|
||||
**board_kwargs,
|
||||
)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
@@ -894,6 +903,10 @@ def _update_board_payload(slug, body):
|
||||
raise ValueError(f"invalid board slug: {slug!r}") from exc
|
||||
if not normed or not kb.board_exists(normed):
|
||||
raise LookupError(f"board {slug!r} does not exist")
|
||||
board_kwargs = {}
|
||||
if "default_workdir" in body:
|
||||
raw_workdir = str(body.get("default_workdir") or "").strip()
|
||||
board_kwargs["default_workdir"] = str(resolve_trusted_workspace(raw_workdir)) if raw_workdir else ""
|
||||
archived = body.get("archived")
|
||||
if isinstance(archived, str):
|
||||
archived = archived.strip().lower() in {"1", "true", "yes", "on"}
|
||||
@@ -904,6 +917,7 @@ def _update_board_payload(slug, body):
|
||||
icon=body.get("icon"),
|
||||
color=body.get("color"),
|
||||
archived=archived if isinstance(archived, bool) else None,
|
||||
**board_kwargs,
|
||||
)
|
||||
return {"board": _board_meta_dict(meta), "read_only": False}
|
||||
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Per-message media snapshots: freeze file bytes at message-settle time.
|
||||
|
||||
Problem
|
||||
-------
|
||||
``/api/media`` serves a file's CURRENT bytes. Since the ETag-revalidation
|
||||
work (#6922) made browsers revalidate on every use, an in-place overwrite of
|
||||
``report.html`` also rewrites every historical chat preview that referenced
|
||||
it — the user loses the old/new comparison they had when the agent emitted
|
||||
the file twice under the same name.
|
||||
|
||||
This module is the storage half of the fix. When a turn settles
|
||||
(``api/streaming.py``), every local-file ``MEDIA:`` reference in the new
|
||||
assistant messages is snapshotted into a content-addressed store:
|
||||
|
||||
<STATE_DIR>/media_snapshots/<sha256>.snap
|
||||
|
||||
Content addressing gives free dedup (an unchanged file re-settles to the
|
||||
same digest and the copy is skipped) and makes every stored object
|
||||
IMMUTABLE — the serving side can therefore cache snapshots aggressively.
|
||||
Messages carry a ``_media_snapshots`` annotation mapping absolute path to
|
||||
digest (sidecar JSON preserves extra message fields); the frontend appends
|
||||
``&snap=<digest>`` to ``/api/media`` URLs and ``api/routes.py`` serves the
|
||||
stored bytes instead of the live file.
|
||||
|
||||
Security model
|
||||
--------------
|
||||
* Digests are validated by whole-string ``fullmatch`` against ``[0-9a-f]{64}``
|
||||
before any path is built — a crafted ``snap=`` value cannot traverse out of
|
||||
the store.
|
||||
* Capture uses the SAME deny predicate as the ``/api/media`` serve path
|
||||
(``routes._media_deny_reason``): anything the endpoint refuses to serve is
|
||||
never captured in the first place.
|
||||
* Every captured digest carries a server-owned source-path binding; a digest
|
||||
is only ever served back for the exact canonical path it was captured from,
|
||||
so it can never act as a bearer capability through a different allowed path.
|
||||
* The store lives under STATE_DIR; capture is restricted to files that are
|
||||
regular files within caller-approved roots (the caller reuses the same
|
||||
allow-list reasoning as ``/api/media`` — this module never decides what a
|
||||
user may see, only stores bytes it is handed).
|
||||
* Writes are tmp-file + fsync + rename: a crash never leaves a torn
|
||||
snapshot that would serve corrupt bytes forever (content addressing means
|
||||
a torn file could never be overwritten with good bytes later).
|
||||
|
||||
Caps
|
||||
----
|
||||
* Per-file cap (default 50 MB): larger files are not snapshotted; previews
|
||||
for them gracefully degrade to the live file.
|
||||
* Total store cap (default 2 GB): when exceeded after a capture, oldest
|
||||
snapshots (by mtime) are evicted until the store fits. Eviction only
|
||||
degrades OLD previews to live-file behaviour; it never corrupts anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("hermes.webui")
|
||||
|
||||
# Strict whole-string shape. ``\\Z`` (not ``$``) so a terminal newline cannot
|
||||
# sneak past the gate; ``fullmatch`` is used at call sites.
|
||||
_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
|
||||
# Default caps. Overridable via env var for operators with unusual disks.
|
||||
DEFAULT_MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MB per snapshot
|
||||
DEFAULT_TOTAL_CAP_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB total store
|
||||
|
||||
_SNAPSHOT_DIR_ENV = "HERMES_WEBUI_MEDIA_SNAPSHOT_DIR"
|
||||
|
||||
# Capture and eviction run on the streaming worker thread; serialize them so
|
||||
# two concurrent settles cannot race the same tmp file or the quota scan.
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
def media_capture_allowed(path: Path) -> bool:
|
||||
"""Allow-list predicate for snapshot capture (same gate as serve).
|
||||
|
||||
True only when ``path`` is a regular file inside an allowed root and NOT
|
||||
inside a denied Hermes-internal state location. The deny half is the
|
||||
SAME predicate the ``/api/media`` serve path uses (``routes._media_deny_reason``,
|
||||
the #3234 state/profile deny set): anything the endpoint would refuse to
|
||||
serve is never captured in the first place — capture and serve can never
|
||||
diverge on what is denied. Any failure mode returns False —
|
||||
snapshotting is best-effort durability, never a reason to widen file
|
||||
access.
|
||||
"""
|
||||
import stat as stat_mod
|
||||
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
st = resolved.stat()
|
||||
except OSError:
|
||||
return False
|
||||
if not stat_mod.S_ISREG(st.st_mode):
|
||||
return False
|
||||
|
||||
within_any_root = any(
|
||||
_path_within(resolved, root) for root in _allowed_roots_for_capture()
|
||||
)
|
||||
if not within_any_root:
|
||||
return False
|
||||
# Authoritative deny parity with the serve path (#6979 Round 2 MUST-FIX 1):
|
||||
# STATE_DIR subdirs, webui_state subdirs, named-profile roots, secret
|
||||
# basenames and the snapshot store itself are all denied exactly as the
|
||||
# route denies them.
|
||||
try:
|
||||
from api.routes import _media_deny_reason
|
||||
|
||||
if _media_deny_reason(resolved):
|
||||
return False
|
||||
except Exception:
|
||||
return False # fail closed: never snapshot when the gate is unclear
|
||||
return True
|
||||
|
||||
|
||||
def _allowed_roots_for_capture() -> list[Path]:
|
||||
"""Roots capture is permitted in — same shape as ``/api/media``'s list."""
|
||||
roots: list[Path] = []
|
||||
home = Path(os.path.expanduser("~"))
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", str(home / ".hermes"))).expanduser()
|
||||
for candidate in (hermes_home, Path("/tmp"), home / ".hermes"):
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if resolved not in roots:
|
||||
roots.append(resolved)
|
||||
try:
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
ws = Path(get_last_workspace()).resolve()
|
||||
if ws.is_dir() and ws not in roots:
|
||||
roots.append(ws)
|
||||
except Exception:
|
||||
pass
|
||||
extra = os.environ.get("MEDIA_ALLOWED_ROOTS", "").strip()
|
||||
if extra:
|
||||
for root in extra.split(os.pathsep):
|
||||
root = root.strip()
|
||||
if not root:
|
||||
continue
|
||||
try:
|
||||
rp = Path(root).resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if rp.is_dir() and rp not in roots:
|
||||
roots.append(rp)
|
||||
return roots
|
||||
|
||||
|
||||
def _path_within(child: Path, root: Path) -> bool:
|
||||
try:
|
||||
child.resolve().relative_to(root)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def resolve_media_ref(raw_ref: str) -> Path | None:
|
||||
"""Map a raw ``MEDIA:`` token to an absolute local path, or None.
|
||||
|
||||
Only bare local paths and ``file://`` URLs resolve; http(s)/data/other
|
||||
schemes return None (nothing to snapshot — they are not server files).
|
||||
``~`` is expanded. No existence check: callers decide whether absence
|
||||
matters (capture skips missing files).
|
||||
"""
|
||||
ref = str(raw_ref or "").strip()
|
||||
if not ref:
|
||||
return None
|
||||
if ref.startswith("data:") or re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", ref):
|
||||
if ref.lower().startswith("file://"):
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(ref)
|
||||
ref = unquote(parsed.path or "")
|
||||
except Exception:
|
||||
ref = ref[len("file://"):]
|
||||
else:
|
||||
return None
|
||||
if not ref or ref.startswith(("http:", "https:")):
|
||||
return None
|
||||
try:
|
||||
return Path(ref).expanduser().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _default_snapshot_dir() -> Path:
|
||||
from api.config import STATE_DIR
|
||||
|
||||
return Path(STATE_DIR) / "media_snapshots"
|
||||
|
||||
|
||||
def get_snapshot_dir() -> Path:
|
||||
"""Snapshot store root (created lazily by capture)."""
|
||||
override = os.getenv(_SNAPSHOT_DIR_ENV, "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return _default_snapshot_dir()
|
||||
|
||||
|
||||
def is_valid_digest(digest: str) -> bool:
|
||||
"""Strict digest shape check — the ONLY gate before path construction.
|
||||
|
||||
``fullmatch`` with a ``\\Z``-anchored pattern: ``$`` would also match
|
||||
before a terminal newline, letting ``<64 hex>\\n`` slip through.
|
||||
"""
|
||||
try:
|
||||
return _DIGEST_RE.fullmatch(str(digest or "")) is not None
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def snapshot_path_for_digest(digest: str) -> Path | None:
|
||||
"""Return the on-disk path for a digest, or None if absent/invalid.
|
||||
|
||||
Never creates anything; serving uses this to decide snapshot vs live-file
|
||||
fallback.
|
||||
"""
|
||||
if not is_valid_digest(digest):
|
||||
return None
|
||||
candidate = get_snapshot_dir() / f"{digest}.snap"
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _binding_path_for_digest(digest: str) -> Path:
|
||||
"""Sidecar holding the source-path set for a digest (``<digest>.src.json``)."""
|
||||
return get_snapshot_dir() / f"{digest}.src.json"
|
||||
|
||||
|
||||
def _record_source_binding(digest: str, source: Path) -> None:
|
||||
"""Persist the server-owned canonical source-path ↔ digest association.
|
||||
|
||||
A digest is only ever served back for the EXACT path it was captured from
|
||||
(see :func:`snapshot_servable_for_path`): a digest must never become a
|
||||
bearer capability readable through any other allowed path. The sidecar is
|
||||
a tiny path list, rewritten tmp+rename atomically. Caller holds
|
||||
``_LOCK`` (capture is serialized), so concurrent settles cannot race it.
|
||||
"""
|
||||
try:
|
||||
binding_file = _binding_path_for_digest(digest)
|
||||
sources: set[str] = set()
|
||||
try:
|
||||
data = json.loads(binding_file.read_text(encoding="utf-8"))
|
||||
sources = set(data.get("sources", []))
|
||||
except (OSError, ValueError, TypeError):
|
||||
sources = set()
|
||||
sources.add(str(Path(source).resolve()))
|
||||
tmp = binding_file.with_name(binding_file.name + ".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"digest": digest, "sources": sorted(sources)}, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, binding_file)
|
||||
except OSError as exc:
|
||||
logger.debug("media snapshot source binding failed for %s: %s", source, exc)
|
||||
|
||||
|
||||
def snapshot_servable_for_path(digest: str, target: Path) -> bool:
|
||||
"""True only when ``digest`` was captured from canonical path ``target``.
|
||||
|
||||
Serve-side half of the source-path binding (#6979 Round 2 MUST-FIX 1):
|
||||
the snapshot branch serves a digest only for the exact authorized path it
|
||||
was captured from, so replaying a digest through a different (allowed)
|
||||
path can never read stored bytes the normal ``path=`` gate would refuse.
|
||||
A missing/invalid sidecar returns False — the caller falls back to the
|
||||
live file.
|
||||
"""
|
||||
if not is_valid_digest(digest):
|
||||
return False
|
||||
try:
|
||||
want = str(target.resolve())
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
data = json.loads(_binding_path_for_digest(digest).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return want in set(data.get("sources", []))
|
||||
|
||||
|
||||
def _total_cap_bytes() -> int:
|
||||
try:
|
||||
return max(0, int(os.getenv("HERMES_WEBUI_MEDIA_SNAPSHOT_CAP_BYTES", "")))
|
||||
except ValueError:
|
||||
return DEFAULT_TOTAL_CAP_BYTES
|
||||
|
||||
|
||||
def _max_file_bytes() -> int:
|
||||
try:
|
||||
return max(0, int(os.getenv("HERMES_WEBUI_MEDIA_SNAPSHOT_MAX_FILE_BYTES", "")))
|
||||
except ValueError:
|
||||
return DEFAULT_MAX_FILE_BYTES
|
||||
|
||||
|
||||
def _store_size_and_entries(directory: Path) -> tuple[int, list[tuple[float, int, Path]]]:
|
||||
"""Scan the store: (total bytes, [(mtime, size, path), ...])."""
|
||||
total = 0
|
||||
entries: list[tuple[float, int, Path]] = []
|
||||
try:
|
||||
for child in directory.iterdir():
|
||||
if child.suffix != ".snap":
|
||||
continue
|
||||
try:
|
||||
st = child.stat()
|
||||
except OSError:
|
||||
continue
|
||||
total += st.st_size
|
||||
entries.append((st.st_mtime, st.st_size, child))
|
||||
except OSError:
|
||||
pass
|
||||
return total, entries
|
||||
|
||||
|
||||
def _enforce_quota_locked(directory: Path) -> None:
|
||||
"""Evict oldest snapshots until the store is under the total cap.
|
||||
|
||||
Caller holds ``_LOCK``. Eviction is best-effort: an unlink failure is
|
||||
logged and skipped, never raised into the settle path.
|
||||
"""
|
||||
cap = _total_cap_bytes()
|
||||
total, entries = _store_size_and_entries(directory)
|
||||
if total <= cap:
|
||||
return
|
||||
entries.sort() # oldest mtime first
|
||||
for _mtime, size, path in entries:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
path.unlink()
|
||||
total -= size
|
||||
logger.info("media snapshot quota: evicted %s (%d bytes)", path.name, size)
|
||||
# Drop the digest's source-binding sidecar with its blob.
|
||||
try:
|
||||
_binding_path_for_digest(path.stem).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("media snapshot eviction failed for %s: %s", path, exc)
|
||||
|
||||
|
||||
def capture_snapshot(source: Path, *, max_file_bytes: int | None = None) -> str | None:
|
||||
"""Copy ``source`` into the content-addressed store; return its digest.
|
||||
|
||||
Returns None (caller falls back to live-file previews) when:
|
||||
* the file is missing / not a regular file / unreadable,
|
||||
* the file exceeds the per-file cap,
|
||||
* any I/O error occurs mid-copy (the torn tmp is removed).
|
||||
|
||||
Never raises — snapshotting is a durability enhancement and must not be
|
||||
able to break the settle path.
|
||||
"""
|
||||
cap = _max_file_bytes() if max_file_bytes is None else max_file_bytes
|
||||
try:
|
||||
st = source.stat()
|
||||
except OSError:
|
||||
return None
|
||||
import stat as stat_mod
|
||||
|
||||
if not stat_mod.S_ISREG(st.st_mode):
|
||||
return None
|
||||
if cap and st.st_size > cap:
|
||||
return None
|
||||
|
||||
with _LOCK:
|
||||
directory = get_snapshot_dir()
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
digest = hashlib.sha256()
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
tmp_path = directory / f".tmp.{os.getpid()}.{threading.get_ident()}"
|
||||
with open(source, "rb") as src, open(tmp_path, "wb") as dst:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
dst.write(chunk)
|
||||
dst.flush()
|
||||
os.fsync(dst.fileno())
|
||||
hex_digest = digest.hexdigest()
|
||||
final_path = directory / f"{hex_digest}.snap"
|
||||
if final_path.exists():
|
||||
# Content already stored (dedup) — drop the duplicate copy.
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
os.replace(tmp_path, final_path)
|
||||
tmp_path = None
|
||||
# Server-owned source-path binding: this digest may only be served
|
||||
# back for THIS canonical path (see snapshot_servable_for_path).
|
||||
_record_source_binding(hex_digest, source)
|
||||
_enforce_quota_locked(directory)
|
||||
return hex_digest
|
||||
except OSError as exc:
|
||||
logger.debug("media snapshot capture failed for %s: %s", source, exc)
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def annotate_media_snapshots(
|
||||
messages: list,
|
||||
*,
|
||||
resolve_ref=None,
|
||||
allowed_predicate=None,
|
||||
) -> int:
|
||||
"""Scan settled messages and snapshot every local-file MEDIA: reference.
|
||||
|
||||
Writes a ``_media_snapshots`` dict ({absolute path: digest}) onto each
|
||||
assistant message that carries at least one local-file ``MEDIA:`` ref.
|
||||
Messages whose refs are already fully annotated are skipped (idempotent
|
||||
across repeated settles). A recorded digest is FINAL: even if its blob is
|
||||
later evicted by quota, a re-settle must not re-capture the CURRENT live
|
||||
bytes and silently rebind the historical message — evicted blobs degrade
|
||||
to live-file serving instead (#6979 Round 2 SHOULD-FIX).
|
||||
|
||||
``resolve_ref(raw_ref) -> Path | None`` maps a raw MEDIA token to an
|
||||
absolute file path (defaults to :func:`resolve_media_ref`); refs it cannot
|
||||
resolve (remote URLs, data: URIs) are ignored. ``allowed_predicate(path)
|
||||
-> bool`` applies the same allow/deny reasoning as ``/api/media``
|
||||
(defaults to :func:`media_capture_allowed`) so the store never receives
|
||||
bytes the endpoint would not serve.
|
||||
|
||||
Returns the number of new snapshots captured (0 on a repeat settle).
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
if resolve_ref is None:
|
||||
resolve_ref = resolve_media_ref
|
||||
if allowed_predicate is None:
|
||||
allowed_predicate = media_capture_allowed
|
||||
media_re = _re.compile(r"MEDIA:([^\s\)\]]+)")
|
||||
captured = 0
|
||||
for msg in messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or "MEDIA:" not in content:
|
||||
continue
|
||||
refs = media_re.findall(content)
|
||||
if not refs:
|
||||
continue
|
||||
existing = msg.get("_media_snapshots")
|
||||
snaps = dict(existing) if isinstance(existing, dict) else {}
|
||||
changed = False
|
||||
for raw_ref in refs:
|
||||
if resolve_ref is not None:
|
||||
try:
|
||||
path = resolve_ref(raw_ref)
|
||||
except Exception:
|
||||
path = None
|
||||
else:
|
||||
path = None
|
||||
if path is None:
|
||||
continue
|
||||
# Index by BOTH the resolved absolute path and the raw token as the
|
||||
# frontend embeds it (file:// unwrapped, ~/ kept verbatim). One of
|
||||
# the two always matches the path= query param in the rendered URL.
|
||||
keys = [str(path)]
|
||||
if raw_ref not in keys:
|
||||
keys.append(raw_ref)
|
||||
# A recorded digest is FINAL once stamped (blob presence is NOT
|
||||
# re-checked): quota eviction must not cause a re-settle to
|
||||
# re-capture the current live bytes and rebind the historical
|
||||
# message — that would defeat per-message immutability and thrash
|
||||
# the store with re-capture I/O. Evicted blobs simply fall back to
|
||||
# live-file serving on the serve side.
|
||||
pending = [k for k in keys if not (snaps.get(k) and is_valid_digest(snaps[k]))]
|
||||
if not pending:
|
||||
continue # already stored under every key — zero-I/O fast path
|
||||
if allowed_predicate is not None:
|
||||
try:
|
||||
if not allowed_predicate(path):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
digest = capture_snapshot(path)
|
||||
if digest:
|
||||
for k in keys:
|
||||
snaps[k] = digest
|
||||
changed = True
|
||||
captured += 1
|
||||
if changed:
|
||||
msg["_media_snapshots"] = snaps
|
||||
return captured
|
||||
+2315
-162
File diff suppressed because it is too large
Load Diff
+30
-1
@@ -35,6 +35,34 @@ from api.workspace import get_last_workspace, load_workspaces
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── OpenRouter namespace translation (#7514) ────────────────────────────────
|
||||
#
|
||||
# ``_FALLBACK_MODELS`` (api/config.py) is authored for the *direct* provider
|
||||
# endpoints, so its Z.AI entries carry the provider's own ``zai/`` prefix.
|
||||
# OpenRouter serves the same models under its canonical ``z-ai/`` slug, so
|
||||
# projecting that list into the OpenRouter setup verbatim handed the wizard
|
||||
# ids that 404 on openrouter.ai (``zai/glm-5.3`` instead of
|
||||
# ``z-ai/glm-5.3``). Translate at this projection boundary only — the
|
||||
# fallback catalog and the direct ``zai`` setup keep their own namespace.
|
||||
#
|
||||
# Every other namespace in the fallback list is already OpenRouter-canonical:
|
||||
# openai/, anthropic/, google/, deepseek/, qwen/, x-ai/, mistralai/, minimax/,
|
||||
# openrouter/, tencent/, nvidia/, arcee-ai/ — hence a single mapping entry.
|
||||
_OPENROUTER_NAMESPACE_MAP = {"zai/": "z-ai/"}
|
||||
|
||||
|
||||
def _to_openrouter_namespace(model_id: str) -> str:
|
||||
"""Return *model_id* with direct-provider prefixes mapped to OpenRouter's.
|
||||
|
||||
Ids whose namespace already matches OpenRouter (or that carry no
|
||||
namespace) are returned unchanged.
|
||||
"""
|
||||
for direct_prefix, openrouter_prefix in _OPENROUTER_NAMESPACE_MAP.items():
|
||||
if model_id.startswith(direct_prefix):
|
||||
return openrouter_prefix + model_id[len(direct_prefix):]
|
||||
return model_id
|
||||
|
||||
|
||||
_SUPPORTED_PROVIDER_SETUPS = {
|
||||
# ── Easy start ──────────────────────────────────────────────────────
|
||||
"openrouter": {
|
||||
@@ -43,7 +71,8 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": [
|
||||
{"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS
|
||||
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
|
||||
for model in _FALLBACK_MODELS
|
||||
],
|
||||
"category": "easy_start",
|
||||
"quick": True,
|
||||
|
||||
@@ -182,6 +182,41 @@ def _host_without_port(host: str) -> str:
|
||||
|
||||
|
||||
def rp_context(handler) -> tuple[str, str]:
|
||||
# A reverse proxy that rewrites Host to its own upstream address (common
|
||||
# when a separate SPA front-end proxies /api to this backend) leaves Host
|
||||
# useless for deriving the RPID, while Origin still carries the origin the
|
||||
# browser is actually on. Prefer Origin's hostname: WebAuthn's RPID-origin
|
||||
# check compares against the page's origin, not the backend's.
|
||||
#
|
||||
# Origin is client-supplied, so this is deliberately NOT a trust decision:
|
||||
# it only selects which name the ceremony is scoped to. The actual security
|
||||
# gates are unchanged and live elsewhere — the authenticator will only
|
||||
# release a credential whose RPID is a registrable suffix of the real page
|
||||
# origin, `_client_data()` requires clientDataJSON.origin to equal the
|
||||
# origin stored with the challenge, `_parse_auth_data()` compares the
|
||||
# authenticator's rpIdHash against that same stored RPID, and the assertion
|
||||
# signature is verified against the stored credential public key. A forged
|
||||
# Origin therefore yields a self-consistent ceremony that still cannot
|
||||
# produce a valid signature.
|
||||
#
|
||||
# Accept it only as a syntactically valid http(s) origin with a hostname,
|
||||
# and rebuild the origin string from the parsed parts rather than echoing
|
||||
# the raw header, so a malformed or non-http Origin falls through to Host.
|
||||
browser_origin = handler.headers.get("Origin", "")
|
||||
if browser_origin:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(browser_origin.strip())
|
||||
if parsed.scheme in ("http", "https") and parsed.hostname:
|
||||
# Re-bracket IPv6 literals: parsed.hostname strips the [] that the
|
||||
# browser's clientDataJSON.origin carries, so an unbracketed
|
||||
# "http://::1:8787" would never match the stored origin.
|
||||
host_part = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
||||
netloc = host_part if parsed.port is None else f"{host_part}:{parsed.port}"
|
||||
return parsed.hostname, f"{parsed.scheme}://{netloc}"
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: derive from Host header (direct/internal access)
|
||||
host = _host_without_port(handler.headers.get("Host", "localhost"))
|
||||
proto = handler.headers.get("X-Forwarded-Proto", "").split(",", 1)[0].strip().lower()
|
||||
if proto not in {"http", "https"}:
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -53,6 +55,125 @@ def completion_delivery_id(evt: Any) -> str:
|
||||
return str(evt.get("session_id") or "").strip()
|
||||
|
||||
|
||||
# ── process-wakeup display metadata (#6345) ────────────────────────────────
|
||||
# Inverse of the two structured ``format_wakeup_prompt`` shapes (completion,
|
||||
# watch_match). Those shapes are pinned by
|
||||
# tests/test_background_process_wakeup_format.py; the other event kinds
|
||||
# (watch_overflow/watch_disabled free-text, async_delegation agent-side
|
||||
# formatter) intentionally return None so the UI keeps its raw fallback.
|
||||
_WAKEUP_COMPLETION_RE = re.compile(
|
||||
r"\A\[IMPORTANT: Background process (?P<sid>[^\n]*?) completed "
|
||||
r"\(exit_code=(?P<exit_code>[^)\n]*)\)\.\n"
|
||||
r"Command: (?P<cmd>[^\n]*)\n"
|
||||
r"Output:\n"
|
||||
)
|
||||
_WAKEUP_WATCH_MATCH_RE = re.compile(
|
||||
r"\A\[IMPORTANT: Background process (?P<sid>[^\n]*?) matched watch pattern "
|
||||
r"\"(?P<pattern>.*)\"\.\n"
|
||||
r"Command: (?P<cmd>[^\n]*)\n"
|
||||
r"Matched output:\n"
|
||||
)
|
||||
|
||||
|
||||
def wakeup_display_meta(text: Any) -> dict | None:
|
||||
"""Parse a ``format_wakeup_prompt`` body into display-only metadata.
|
||||
|
||||
Returns ``{type, task_id, command, exit_code}`` for completion events and
|
||||
``{type, task_id, command, pattern}`` for watch matches, or None when the
|
||||
text is not one of those pinned shapes. Header fields only — the output
|
||||
section stays in the message body (the UI extracts it there), so the
|
||||
metadata never duplicates multi-KB process output in the store.
|
||||
|
||||
Header fields are anchored to the pinned single-line grammar (``sid``,
|
||||
``exit_code``, ``command``, ``pattern`` never contain newlines). The
|
||||
optional watch suppression note is deliberately NOT parsed out: it lives in
|
||||
the free-form output tail, where process output can contain the exact same
|
||||
"(N earlier matches were suppressed…)" text, so inferring it from the body
|
||||
would misclassify legitimate output and drop it. The note stays part of the
|
||||
rendered output verbatim (#6350 review finding 2).
|
||||
"""
|
||||
body = str(text or "")
|
||||
m = _WAKEUP_COMPLETION_RE.match(body)
|
||||
if m:
|
||||
exit_code: Any = m.group("exit_code")
|
||||
try:
|
||||
exit_code = int(exit_code)
|
||||
except ValueError:
|
||||
pass
|
||||
return {
|
||||
"type": "completion",
|
||||
"task_id": m.group("sid"),
|
||||
"command": m.group("cmd"),
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
m = _WAKEUP_WATCH_MATCH_RE.match(body)
|
||||
if m:
|
||||
return {
|
||||
"type": "watch_match",
|
||||
"task_id": m.group("sid"),
|
||||
"command": m.group("cmd"),
|
||||
"pattern": m.group("pattern"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def attach_wakeup_display_meta(msg: Any, source: Any) -> None:
|
||||
"""Stamp ``_wakeup_meta`` on a process-wakeup user message, best-effort.
|
||||
|
||||
Companion to the ``_source`` stamp: display-only (``_wakeup_meta`` is not
|
||||
in ``_API_SAFE_MSG_KEYS``, so it never reaches a provider) and never
|
||||
raises — an unparseable body simply leaves the message unstamped and the
|
||||
UI falls back to parsing/raw rendering.
|
||||
"""
|
||||
if source != "process_wakeup" or not isinstance(msg, dict):
|
||||
return
|
||||
if msg.get("_wakeup_meta"):
|
||||
return
|
||||
try:
|
||||
meta = wakeup_display_meta(msg.get("content"))
|
||||
except Exception:
|
||||
logger.debug("wakeup display-meta derivation failed", exc_info=True)
|
||||
return
|
||||
if meta:
|
||||
msg["_wakeup_meta"] = meta
|
||||
|
||||
|
||||
def build_active_turn_token(stream_id: Any, started_at: Any) -> str | None:
|
||||
"""Return the exact eager-row token for one active WebUI turn."""
|
||||
if not stream_id:
|
||||
return None
|
||||
try:
|
||||
started = float(started_at)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(started) or started <= 0:
|
||||
return None
|
||||
return f"{str(stream_id).strip()}:{started:.17g}"
|
||||
|
||||
|
||||
def stamp_message_source(
|
||||
msg: Any,
|
||||
source: Any,
|
||||
*,
|
||||
active_turn_token: Any = None,
|
||||
) -> None:
|
||||
"""Stamp ``_source`` and any display metadata on a materialized user turn.
|
||||
|
||||
Single choke point for every path that persists a non-``webui`` user turn
|
||||
(result merges, eager checkpoint, and the pending-turn recovery paths) so a
|
||||
future source-bearing recovery site cannot silently skip the ``_wakeup_meta``
|
||||
stamp — the gap #6350 review flagged in ``_append_recovered_pending_turn``
|
||||
and the cancel outer-finally recovery. ``webui`` turns are left untouched to
|
||||
preserve the existing "``_source`` omitted for the default source" contract.
|
||||
"""
|
||||
if isinstance(msg, dict) and active_turn_token:
|
||||
msg["_active_turn_token"] = str(active_turn_token)
|
||||
if not isinstance(msg, dict) or not source or source == "webui":
|
||||
return
|
||||
msg["_source"] = source
|
||||
attach_wakeup_display_meta(msg, source)
|
||||
|
||||
|
||||
def _claim_bounded_local(delegation_id: str) -> bool:
|
||||
with _LEGACY_ASYNC_DELIVERY_LOCK:
|
||||
if delegation_id in _LEGACY_ASYNC_DELIVERY_IDS:
|
||||
|
||||
+38
-13
@@ -1711,19 +1711,22 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
default_workspace = None
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _DW
|
||||
from api.workspace import _resolve_path, _remote_terminal_workspace_candidate
|
||||
lw_file = home / 'webui_state' / 'last_workspace.txt'
|
||||
if lw_file.exists():
|
||||
_p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if _p:
|
||||
_pp = Path(_p).expanduser()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp.resolve())
|
||||
_pp = _resolve_path(_p, profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(_p, profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
for _key in ('workspace', 'default_workspace'):
|
||||
_v = cfg.get(_key)
|
||||
if _v:
|
||||
_pp = Path(str(_v)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_v), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_v), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
break
|
||||
if default_workspace is None:
|
||||
@@ -1731,8 +1734,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
if isinstance(_tc, dict):
|
||||
_cwd = _tc.get('cwd', '')
|
||||
if _cwd and str(_cwd) not in ('.', ''):
|
||||
_pp = Path(str(_cwd)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_cwd), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_cwd), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
default_workspace = str(_DW)
|
||||
@@ -2114,7 +2118,7 @@ def list_profiles_api() -> list:
|
||||
else:
|
||||
rows = _build_profile_rows_fast()
|
||||
if rows is not None:
|
||||
_LIST_PROFILES_CACHE = (rows, now)
|
||||
_LIST_PROFILES_CACHE = (rows, time.time())
|
||||
|
||||
if rows is None:
|
||||
# Fallback: cheap helpers unavailable — use the original (slow) path,
|
||||
@@ -2393,20 +2397,41 @@ def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[st
|
||||
|
||||
|
||||
def _split_webui_provider_model_value(default_model: Optional[str], model_provider: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Normalize WebUI-internal @provider:model picker values for config.yaml."""
|
||||
"""Normalize WebUI-internal @provider:model picker values for config.yaml.
|
||||
|
||||
Parsing is delegated to ``config._parse_provider_qualified_model_id()`` so
|
||||
this agrees with the grammar every other call site uses (#6722, #6723). A
|
||||
positional ``rsplit(":", 1)`` cannot tell a colon-tagged model
|
||||
(``@ollama:qwen3.8:27b-mtp-q8_0``) from a multi-segment custom provider ID
|
||||
(``@custom:backup:model-a``); it truncated the model name and persisted the
|
||||
fragment into profile config, which the provider API then 404'd on (#7182).
|
||||
"""
|
||||
model = _clean_profile_config_value(default_model, "default_model")
|
||||
provider = _clean_profile_config_value(model_provider, "model_provider")
|
||||
if model and model.startswith("@") and ":" in model:
|
||||
provider_part, model_part = model[1:].rsplit(":", 1)
|
||||
provider = provider or _clean_profile_config_value(provider_part, "model_provider")
|
||||
model = _clean_profile_config_value(model_part, "default_model")
|
||||
from api.config import _parse_provider_qualified_model_id
|
||||
|
||||
parsed = _parse_provider_qualified_model_id(model)
|
||||
if parsed:
|
||||
model_part, provider_part = parsed
|
||||
provider = provider or _clean_profile_config_value(provider_part, "model_provider")
|
||||
model = _clean_profile_config_value(model_part, "default_model")
|
||||
return model, provider
|
||||
|
||||
|
||||
def _strip_webui_provider_prefix(model_id: object) -> str:
|
||||
"""Return the bare model name from a WebUI ``@provider:model`` value.
|
||||
|
||||
Uses the same shared grammar as ``_split_webui_provider_model_value()`` so a
|
||||
tagged model name survives the round trip (#7182).
|
||||
"""
|
||||
value = str(model_id or "").strip()
|
||||
if value.startswith("@") and ":" in value:
|
||||
return value.rsplit(":", 1)[1]
|
||||
from api.config import _parse_provider_qualified_model_id
|
||||
|
||||
parsed = _parse_provider_qualified_model_id(value)
|
||||
if parsed:
|
||||
return str(parsed[0] or "").strip()
|
||||
return value
|
||||
|
||||
|
||||
|
||||
+41
-6
@@ -36,6 +36,7 @@ from api.config import (
|
||||
_PROVIDER_DISPLAY,
|
||||
_PROVIDER_MODELS,
|
||||
_coerce_provider_cost_budget,
|
||||
_configured_model_ids,
|
||||
_custom_provider_slug_from_name,
|
||||
_get_label_for_model,
|
||||
_models_from_live_provider_ids,
|
||||
@@ -424,6 +425,19 @@ def _entry_exhausted_ttl_seconds(error_code):
|
||||
code = str(error_code or "").strip()
|
||||
if code == "401":
|
||||
return 5 * 60
|
||||
if code == "402":
|
||||
# #6626: keep WebUI's eligibility decision tied to the installed
|
||||
# runtime contract. The runtime routes 402 via
|
||||
# credential_pool._exhausted_ttl() (120s when the new
|
||||
# EXHAUSTED_TTL_402_SECONDS is present, 1h fallback otherwise).
|
||||
# Hard-coding 120s here would let display/probe code mark an entry
|
||||
# usable before CredentialPool.select() is willing to lease it on
|
||||
# mixed-version installations.
|
||||
try:
|
||||
from agent.credential_pool import _exhausted_ttl as _runtime_exhausted_ttl
|
||||
return _runtime_exhausted_ttl(int(code))
|
||||
except Exception:
|
||||
return 60 * 60
|
||||
return 60 * 60
|
||||
|
||||
|
||||
@@ -823,6 +837,19 @@ def _entry_exhausted_ttl_seconds(error_code):
|
||||
code = str(error_code or "").strip()
|
||||
if code == "401":
|
||||
return 5 * 60
|
||||
if code == "402":
|
||||
# #6626: keep WebUI's eligibility decision tied to the installed
|
||||
# runtime contract. The runtime routes 402 via
|
||||
# credential_pool._exhausted_ttl() (120s when the new
|
||||
# EXHAUSTED_TTL_402_SECONDS is present, 1h fallback otherwise).
|
||||
# Hard-coding 120s here would let display/probe code mark an entry
|
||||
# usable before CredentialPool.select() is willing to lease it on
|
||||
# mixed-version installations.
|
||||
try:
|
||||
from agent.credential_pool import _exhausted_ttl as _runtime_exhausted_ttl
|
||||
return _runtime_exhausted_ttl(int(code))
|
||||
except Exception:
|
||||
return 60 * 60
|
||||
return 60 * 60
|
||||
|
||||
|
||||
@@ -2809,12 +2836,20 @@ def get_providers() -> dict[str, Any]:
|
||||
cp_name,
|
||||
)
|
||||
continue
|
||||
# Collect models from `models` list or `model` single
|
||||
cp_models = []
|
||||
if isinstance(cp.get("models"), list):
|
||||
cp_models = [{"id": str(m), "label": str(m)} for m in cp["models"]]
|
||||
elif cp.get("model"):
|
||||
cp_models = [{"id": cp["model"], "label": cp["model"]}]
|
||||
# Build the model list using the same sticky-before-plural
|
||||
# ordering as the model picker (api/config.py:7308-7314):
|
||||
# the singular ``model`` field goes first, then unique IDs from
|
||||
# the ``models`` catalog are appended via _configured_model_ids
|
||||
# (which strips whitespace, drops empty IDs, and de-duplicates).
|
||||
# This keeps the Providers card consistent with the picker.
|
||||
cp_model_ids: list[str] = []
|
||||
_singular_model = str(cp.get("model") or "").strip()
|
||||
if _singular_model:
|
||||
cp_model_ids.append(_singular_model)
|
||||
for _mid in _configured_model_ids(cp.get("models")):
|
||||
if _mid not in cp_model_ids:
|
||||
cp_model_ids.append(_mid)
|
||||
cp_models = [{"id": mid, "label": mid} for mid in cp_model_ids]
|
||||
# Check for env var reference (${VAR_NAME} pattern)
|
||||
cp_api_key = str(cp.get("api_key") or "")
|
||||
cp_has_key = bool(cp_api_key.strip())
|
||||
|
||||
@@ -17,6 +17,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Checkpoint identifiers are SHA-style hex hashes from the agent's
|
||||
@@ -102,6 +104,7 @@ def _checkpoint_entry_modes(git: str, ckpt_dir: Path) -> dict[str, int]:
|
||||
result = subprocess.run(
|
||||
[git, "-C", str(ckpt_dir), "ls-files", "-s"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ValueError("Failed to list checkpoint files")
|
||||
@@ -136,6 +139,7 @@ def _read_checkpoint_blob(git: str, ckpt_dir: Path, rel_path: str) -> bytes | No
|
||||
result = subprocess.run(
|
||||
[git, "-C", str(ckpt_dir), "show", f"HEAD:{rel_path}"],
|
||||
capture_output=True, timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
@@ -250,6 +254,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
|
||||
result = subprocess.run(
|
||||
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
@@ -272,6 +277,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
|
||||
files_result = subprocess.run(
|
||||
[git, "-C", str(ckpt_path), "ls-files"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
|
||||
|
||||
|
||||
+355
-24
@@ -6,6 +6,7 @@ Extracts approval state, not handlers, by design.
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
|
||||
from api.session_events import publish_session_list_changed
|
||||
|
||||
@@ -50,6 +51,95 @@ _GATEWAY_MIRROR_RETAINED = "_gateway_mirror_retained"
|
||||
_GATEWAY_ENTRY_DATA_TOKEN_KEY = "_webui_mirror_token"
|
||||
_GATEWAY_AGENT_IDENTITY_V1 = "_gateway_agent_identity_v1"
|
||||
_gateway_relay_owners: dict[tuple[str, str], str] = {}
|
||||
_yolo_transition_lock = threading.Lock()
|
||||
_yolo_transitions: dict[str, dict] = {}
|
||||
_gateway_yolo_handoff_guard = threading.Lock()
|
||||
_gateway_yolo_handoffs: dict[str, dict] = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def gateway_yolo_handoff(session_key: str):
|
||||
"""Serialize one session's YOLO toggles with gateway approval dispatch."""
|
||||
session_key = str(session_key or "").strip()
|
||||
with _gateway_yolo_handoff_guard:
|
||||
entry = _gateway_yolo_handoffs.get(session_key)
|
||||
if entry is None:
|
||||
entry = {"lock": threading.Lock(), "users": 0}
|
||||
_gateway_yolo_handoffs[session_key] = entry
|
||||
entry["users"] += 1
|
||||
lock = entry["lock"]
|
||||
lock.acquire()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
with _gateway_yolo_handoff_guard:
|
||||
entry["users"] -= 1
|
||||
if entry["users"] == 0:
|
||||
_gateway_yolo_handoffs.pop(session_key, None)
|
||||
|
||||
|
||||
def begin_session_yolo_transition(session_key: str) -> object | None:
|
||||
"""Register a pending YOLO enable until one approval relay settles.
|
||||
|
||||
Multiple tabs may relay approvals for different runs in the same session.
|
||||
Track every in-flight enable intent so one failed relay cannot undo another
|
||||
successful or explicit enable. Do not publish an unconfirmed enable to the
|
||||
shared session flag: the gateway stream may only auto-approve later prompts
|
||||
after a relay succeeds or an explicit enable wins.
|
||||
"""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key:
|
||||
return None
|
||||
token = object()
|
||||
with _yolo_transition_lock:
|
||||
transition = _yolo_transitions.get(session_key)
|
||||
if transition is None:
|
||||
transition = {
|
||||
"was_enabled": bool(is_session_yolo_enabled(session_key)),
|
||||
"tokens": set(),
|
||||
"committed": False,
|
||||
}
|
||||
_yolo_transitions[session_key] = transition
|
||||
transition["tokens"].add(token)
|
||||
return token
|
||||
|
||||
|
||||
def finish_session_yolo_transition(session_key: str, token: object | None, *, succeeded: bool) -> None:
|
||||
"""Settle one pending YOLO enable without exposing or applying stale state."""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key or token is None:
|
||||
return
|
||||
with _yolo_transition_lock:
|
||||
transition = _yolo_transitions.get(session_key)
|
||||
if transition is None or token not in transition["tokens"]:
|
||||
return
|
||||
transition["tokens"].remove(token)
|
||||
if succeeded:
|
||||
transition["committed"] = True
|
||||
# The first confirmed relay commits YOLO immediately. Any remaining
|
||||
# tokens may fail later but cannot revoke this successful enable.
|
||||
enable_session_yolo(session_key)
|
||||
if transition["tokens"]:
|
||||
return
|
||||
_yolo_transitions.pop(session_key, None)
|
||||
if transition["committed"] or transition["was_enabled"]:
|
||||
enable_session_yolo(session_key)
|
||||
else:
|
||||
disable_session_yolo(session_key)
|
||||
|
||||
|
||||
def set_session_yolo_enabled(session_key: str, enabled: bool) -> None:
|
||||
"""Apply an explicit YOLO choice and supersede in-flight rollbacks."""
|
||||
session_key = str(session_key or "").strip()
|
||||
if not session_key:
|
||||
return
|
||||
with _yolo_transition_lock:
|
||||
_yolo_transitions.pop(session_key, None)
|
||||
if enabled:
|
||||
enable_session_yolo(session_key)
|
||||
else:
|
||||
disable_session_yolo(session_key)
|
||||
|
||||
|
||||
def _approval_sse_subscribe(session_id: str) -> queue.Queue:
|
||||
@@ -150,29 +240,51 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
|
||||
live_head_entry = live_gateway_queue[0] if live_gateway_queue else None
|
||||
live_head_data = getattr(live_head_entry, "data", None) or {}
|
||||
has_no_run_mirror = any(
|
||||
_is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
for entry in queue_list
|
||||
)
|
||||
live_run_id = str(live_head_data.get("run_id") or "").strip()
|
||||
live_has_token = bool(live_head_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY))
|
||||
# Tokenize EVERY live no-run producer, and derive `live_token` from the
|
||||
# authoritative head, whenever `_gateway_queues[session_key]` has live
|
||||
# producers. This deliberately does NOT defer to a pre-existing no-run
|
||||
# mirror: an unmatched/tokenless mirror A (which the fail-closed binding in
|
||||
# submit_gateway_pending_mirror leaves deliberately unbound) must never
|
||||
# suppress the live producer's token, or A masks the real pending approval
|
||||
# B — B never surfaces as the head and can't be actioned, while responding
|
||||
# to A resolves nothing. While any producer is live, a mirror survives only
|
||||
# if it is bound to some live producer's own token — the head's via
|
||||
# `live_token`, a non-head producer's via `live_local_tokens` (which is what
|
||||
# keeps a non-head mirror resolvable) — so unmatched and tokenless copies
|
||||
# are discarded instead of masking a real one. Tokenless-orphan retention is
|
||||
# reserved for the genuine no-producer case (#7093), which lands here with an
|
||||
# empty `live_gateway_queue` and therefore a `None` `live_token` anyway.
|
||||
live_local_tokens: set[str] = set()
|
||||
for live_entry in live_gateway_queue:
|
||||
live_data = getattr(live_entry, "data", None) or {}
|
||||
if str(live_data.get("run_id") or "").strip():
|
||||
continue
|
||||
live_entry_token = str(live_data.get(_GATEWAY_ENTRY_DATA_TOKEN_KEY) or "").strip()
|
||||
if live_entry_token or not has_no_run_mirror:
|
||||
live_entry_token = _gateway_mirror_entry_token(live_entry) or ""
|
||||
if live_entry_token:
|
||||
live_local_tokens.add(live_entry_token)
|
||||
if not str(live_data.get("approval_id") or "").strip():
|
||||
live_data["approval_id"] = f"gwlocal:{live_entry_token}"
|
||||
live_entry_token = _gateway_mirror_entry_token(live_entry) or ""
|
||||
if live_entry_token:
|
||||
live_local_tokens.add(live_entry_token)
|
||||
if not str(live_data.get("approval_id") or "").strip():
|
||||
live_data["approval_id"] = f"gwlocal:{live_entry_token}"
|
||||
|
||||
# A raw id-less LOCAL entry in `_pending` (agent-side _pending_result drops
|
||||
# `{command, pattern_key, pattern_keys, description}` verbatim when no
|
||||
# gateway notifier is registered) is unactionable by contract: the frontend
|
||||
# owner-capture requires an approval_id, so its card can neither be
|
||||
# approved nor dismissed — the poll re-serves it forever. Mint a stable id
|
||||
# on the entry itself (the same contract the producer-tokenization loop
|
||||
# above applies to _gateway_queues producers). Minting on the stored dict
|
||||
# keeps the id stable across polls, so frontend dismiss markers persist.
|
||||
for entry in queue_list:
|
||||
if not isinstance(entry, dict) or _is_gateway_mirror_entry(entry):
|
||||
continue
|
||||
if str(entry.get("approval_id") or "").strip():
|
||||
continue
|
||||
if str(entry.get("run_id") or "").strip():
|
||||
continue
|
||||
entry["approval_id"] = f"gwlocal-mirrorless:{uuid.uuid4().hex}"
|
||||
live_token = (
|
||||
_gateway_mirror_entry_token(live_head_entry)
|
||||
if live_head_entry and live_head_data
|
||||
and (live_run_id or live_has_token or not has_no_run_mirror)
|
||||
else None
|
||||
)
|
||||
if live_token and live_run_id and not str(live_head_data.get("approval_id") or "").strip():
|
||||
@@ -210,18 +322,28 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
live_mirror_present = True
|
||||
continue
|
||||
if live_token:
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED):
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
if entry_token:
|
||||
changed = True
|
||||
continue
|
||||
deferred_run_entries.append(entry)
|
||||
continue
|
||||
if not entry_token:
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED) or not entry_token:
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED):
|
||||
# A retained mirror is one whose own producer had already vanished when
|
||||
# the user responded (the missing-producer 409 kept visible until an
|
||||
# explicit teardown). It survives ONLY while no producer is live: once
|
||||
# `_gateway_queues[session_key]` holds a real producer again, an
|
||||
# unresolvable retained mirror must not mask it, so fall through to the
|
||||
# normal matching below (which keeps it if it still matches a live
|
||||
# token and discards it otherwise).
|
||||
if entry.get(_GATEWAY_MIRROR_RETAINED) and not live_gateway_queue:
|
||||
rebuilt.append(entry)
|
||||
continue
|
||||
|
||||
@@ -276,10 +398,16 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
return head, total, changed
|
||||
|
||||
|
||||
def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
def _gateway_pending_mirror_locked(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> dict | None:
|
||||
"""Return the exact live run-backed mirror under `_lock`."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
mirror_token = str(mirror_token or "").strip()
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
if approval_id:
|
||||
@@ -296,6 +424,8 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_
|
||||
continue
|
||||
if run_id and entry_run_id != run_id:
|
||||
continue
|
||||
if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token:
|
||||
continue
|
||||
if run_id:
|
||||
return entry
|
||||
if matched_entry is not None:
|
||||
@@ -305,19 +435,46 @@ def _gateway_pending_mirror_locked(session_key: str, approval_id: str = "", run_
|
||||
for entry in entries:
|
||||
if not _is_gateway_mirror_entry(entry) or not str(entry.get("run_id") or "").strip():
|
||||
continue
|
||||
if run_id and entry.get("run_id") == run_id:
|
||||
return entry
|
||||
if mirror_token and str(entry.get(_GATEWAY_MIRROR_TOKEN) or "").strip() != mirror_token:
|
||||
continue
|
||||
if run_id:
|
||||
if entry.get("run_id") == run_id:
|
||||
return entry
|
||||
continue
|
||||
# With no caller-supplied identity, the queue order is authoritative:
|
||||
# return the current run-backed projection and let its embedded
|
||||
# `(approval_id, run_id)` identify the exact relay owner.
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> dict | None:
|
||||
def gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> dict | None:
|
||||
"""Return an exact live run-backed mirror for this session."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
entry = _gateway_pending_mirror_locked(session_key, approval_id, run_id, mirror_token)
|
||||
return dict(entry) if entry else None
|
||||
|
||||
|
||||
def gateway_pending_mirrors(session_key: str) -> list[dict]:
|
||||
"""Return every currently parked run-backed mirror in queue order."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
return [
|
||||
dict(entry)
|
||||
for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def claim_gateway_approval_relay_owner(session_key: str, run_id: str, approval_id: str) -> bool:
|
||||
"""Claim the single-flight relay owner for one `(session, run)` pair."""
|
||||
session_key = str(session_key or "").strip()
|
||||
@@ -348,7 +505,12 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval
|
||||
_gateway_relay_owners.pop(key, None)
|
||||
|
||||
|
||||
def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_id: str = "") -> bool:
|
||||
def retire_gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> bool:
|
||||
"""Retire one approval, or every mirror for a terminal run."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
@@ -359,7 +521,12 @@ def retire_gateway_pending_mirror(session_key: str, approval_id: str = "", run_i
|
||||
retained_gateway_queue = gateway_queue
|
||||
gateway_queue_changed = False
|
||||
if approval_id:
|
||||
match = _gateway_pending_mirror_locked(session_key, approval_id, run_id)
|
||||
match = _gateway_pending_mirror_locked(
|
||||
session_key,
|
||||
approval_id,
|
||||
run_id,
|
||||
mirror_token,
|
||||
)
|
||||
if match is None and not normalized_run_id:
|
||||
match = next((entry for entry in entries if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
@@ -419,7 +586,30 @@ def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str
|
||||
|
||||
|
||||
def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dict | None, int]:
|
||||
"""Mirror the live gateway head into WebUI polling state under a typed tag."""
|
||||
"""Mirror the live gateway head into WebUI polling state under a typed tag.
|
||||
|
||||
Every mirrored entry describes one pending approval to the UI. Run-backed
|
||||
mirrors carry ``run_id`` (remote gateway runs) and are bound to the parked
|
||||
``_ApprovalEntry`` via ``approval_id``. No-run mirrors, which represent an
|
||||
in-process (legacy) approval parked in ``_gateway_queues``, have no
|
||||
``run_id`` and instead bind back to the live entry through
|
||||
``_GATEWAY_MIRROR_TOKEN``: ``_resolve_approval_legacy()`` matches
|
||||
``pending[_GATEWAY_MIRROR_TOKEN]`` against the live entry's
|
||||
``_webui_mirror_token`` before it will call ``resolve_gateway_pending_local()``
|
||||
to unblock the agent thread. A no-run mirror's token is stamped from its
|
||||
OWN live producer — resolved via the mirror's ``request_id``/
|
||||
``approval_id`` — and from nothing else. If no live producer's identity
|
||||
matches, the mirror is left tokenless (fail closed): guessing ownership
|
||||
from "first unclaimed token" or the queue head would let approving THIS
|
||||
(possibly stale/foreign) mirror resolve a DIFFERENT live producer than
|
||||
the one the user actually saw, which is an approval-integrity violation,
|
||||
not a convenience. A tokenless mirror is only wired up automatically when
|
||||
there is truly no live producer at all (#7093); ``reconcile_gateway_
|
||||
pending_mirror_locked`` binds a fresh, correctly-bound mirror to the
|
||||
authoritative live head on its own. Without a token, THIS specific click
|
||||
returns ``ok:true`` and the card clears without unblocking any producer —
|
||||
the correct producer's own card reappears on the next reconcile.
|
||||
"""
|
||||
with _lock:
|
||||
run_id = str(approval.get("run_id") or "").strip()
|
||||
approval_id = str(approval.get("approval_id") or "").strip()
|
||||
@@ -439,6 +629,29 @@ def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dic
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_local_entry is None and not run_id:
|
||||
# Fall back to matching on the core's per-approval `request_id`.
|
||||
# The gateway core notifies WebUI with a COPY of the entry payload
|
||||
# (`notify_cb(dict(entry.data))`), so the identity match above
|
||||
# (`entry.data is approval`) never holds for a real gateway head,
|
||||
# and a local `_ApprovalEntry` carries a `request_id` but no
|
||||
# `approval_id`, so the approval_id fallback misses too. The
|
||||
# `request_id` is stamped once on the source entry
|
||||
# (`_ApprovalEntry.__init__` -> `data.setdefault("request_id", ...)`)
|
||||
# and preserved through the copy, so it uniquely reunites the
|
||||
# notified copy with its queued entry. Without this, the mirror is
|
||||
# created with no token, reconcile keeps the orphan, and
|
||||
# `_session_has_pending_approval` stays True after the entry is
|
||||
# dropped (the stale-approval-card dead-end, #4948 local variant).
|
||||
request_id = str(approval.get("request_id") or "").strip()
|
||||
if request_id:
|
||||
exact_local_entry = next(
|
||||
(
|
||||
entry for entry in live_gateway_queue
|
||||
if str(((getattr(entry, "data", None) or {}).get("request_id") or "")).strip() == request_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_local_entry is not None:
|
||||
mirror_entries = _normalize_pending_queue_locked(session_key)
|
||||
entries_to_mirror = [live_gateway_queue[0]] if live_gateway_queue else []
|
||||
@@ -500,6 +713,8 @@ def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dic
|
||||
mirror_entry["run_id"] = run_id
|
||||
mirror_entry["approval_id"] = approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
mirror_entry[_GATEWAY_MIRROR_TOKEN] = uuid.uuid4().hex
|
||||
mirror_entry[_GATEWAY_MIRROR_RETAINED] = True
|
||||
if not _gateway_pending_mirror_locked(session_key, approval_id=approval_id, run_id=run_id):
|
||||
_normalize_pending_queue_locked(session_key).append(mirror_entry)
|
||||
elif not exact_local_entry:
|
||||
@@ -520,9 +735,51 @@ def submit_gateway_pending_mirror(session_key: str, approval: dict) -> tuple[dic
|
||||
if no_run_mirror:
|
||||
approval["approval_id"] = str(no_run_mirror.get("approval_id") or approval_id).strip()
|
||||
elif not _gateway_pending_mirror_locked(session_key, approval_id=approval_id):
|
||||
# Stamp the mirror token from the mirror's OWN live producer so
|
||||
# the first respond can link this mirror to the right
|
||||
# _ApprovalEntry in _gateway_queues. Without it,
|
||||
# _resolve_approval_legacy cannot match the no-run mirror to its
|
||||
# gateway entry (both token fields are empty) and the agent
|
||||
# thread is never unblocked on the first click (#6008 legacy).
|
||||
# We MUST NOT blindly take the live head: for a non-head mirror
|
||||
# (multiple parked producers, #7093 exact-producer isolation)
|
||||
# the head belongs to a sibling, and stamping its token would
|
||||
# bind the mirror to the wrong entry. Only an explicit
|
||||
# request_id/approval_id match may bind a token.
|
||||
#
|
||||
# FAIL CLOSED when no producer's identity matches: never infer
|
||||
# ownership from "first unclaimed token" or the queue head. A
|
||||
# stale/foreign approval (mismatched request_id, or no
|
||||
# identity at all) that borrows another live producer's token
|
||||
# would let approving THIS mirror resolve a DIFFERENT producer
|
||||
# than the one the user actually saw — an approval-integrity
|
||||
# violation, not a convenience (found in review of 818fd2fd).
|
||||
# A tokenless orphan is only legitimate when there is no live
|
||||
# producer at all (#7093); reconcile_gateway_pending_mirror_locked
|
||||
# binds a real mirror to the authoritative live head on its own.
|
||||
live_queue_for_mirror = _gateway_queues.get(session_key) or []
|
||||
request_id_for_mirror = str(approval.get("request_id") or "").strip()
|
||||
mirror_producer = None
|
||||
if request_id_for_mirror or approval_id:
|
||||
for cand in live_queue_for_mirror:
|
||||
cand_data = getattr(cand, "data", None) or {}
|
||||
if (request_id_for_mirror and
|
||||
str(cand_data.get("request_id") or "").strip() == request_id_for_mirror):
|
||||
mirror_producer = cand
|
||||
break
|
||||
if (approval_id and not request_id_for_mirror and
|
||||
str(cand_data.get("approval_id") or "").strip() == approval_id):
|
||||
mirror_producer = cand
|
||||
break
|
||||
mirror_token = (
|
||||
_gateway_mirror_entry_token(mirror_producer)
|
||||
if mirror_producer is not None else None
|
||||
)
|
||||
mirror_entry = dict(approval)
|
||||
mirror_entry["approval_id"] = approval_id
|
||||
mirror_entry[_GATEWAY_MIRROR_FLAG] = True
|
||||
if mirror_token:
|
||||
mirror_entry[_GATEWAY_MIRROR_TOKEN] = mirror_token
|
||||
_normalize_pending_queue_locked(session_key).append(mirror_entry)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
@@ -609,6 +866,80 @@ def resolve_gateway_pending_local_no_run_mirror(
|
||||
return True, 1, head, total
|
||||
|
||||
|
||||
def resolve_gateway_pending_local_all(
|
||||
session_key: str,
|
||||
choice: str,
|
||||
reason: str | None = None,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Resolve every parked local/no-run approval without touching remote runs."""
|
||||
targets = []
|
||||
removed_pending = False
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip():
|
||||
retained_gateway_queue.append(entry)
|
||||
else:
|
||||
targets.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
retained_pending = [
|
||||
entry
|
||||
for entry in entries
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
removed_pending = len(retained_pending) != len(entries)
|
||||
if retained_pending:
|
||||
_pending[session_key] = retained_pending
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
|
||||
for entry in targets:
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
if targets or removed_pending:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
|
||||
def settle_gateway_pending_local_notification(
|
||||
session_key: str,
|
||||
approval: dict,
|
||||
) -> tuple[bool, dict | None, int]:
|
||||
"""Auto-resolve or publish one local approval at the YOLO handoff boundary.
|
||||
|
||||
The Agent adds its blocking entry before invoking WebUI's notify callback.
|
||||
Serialize that callback with session YOLO commit/disable so a waiter arriving
|
||||
after a drain snapshot cannot be parked behind an already-committed enable.
|
||||
Run-backed approvals stay on the Runs API path and are never resolved here.
|
||||
"""
|
||||
with gateway_yolo_handoff(session_key):
|
||||
run_id = str((approval or {}).get("run_id") or "").strip()
|
||||
if not run_id and is_session_yolo_enabled(session_key):
|
||||
_resolved, head, total = resolve_gateway_pending_local_all(
|
||||
session_key,
|
||||
"once",
|
||||
)
|
||||
return True, head, total
|
||||
head, total = submit_gateway_pending_mirror(session_key, approval)
|
||||
return False, head, total
|
||||
|
||||
|
||||
def submit_pending(session_key: str, approval: dict) -> None:
|
||||
"""Append a pending approval to the per-session queue.
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Session-list cache helpers extracted from api.routes."""
|
||||
|
||||
import os
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -12,6 +13,14 @@ from api.models import _active_state_db_path, _active_stream_ids
|
||||
from api.profiles import _profiles_match
|
||||
|
||||
|
||||
# Cron session ids are ``cron_{job_id}_{run_timestamp}`` where the run
|
||||
# timestamp is ``YYYYMMDD_HHMMSS`` (e.g. cron_job6728_20260803_100000). Used to
|
||||
# validate that the text after a matched ``cron_{jid}_`` prefix is EXACTLY a run
|
||||
# timestamp, so a shorter job id (backup) cannot claim a longer job id's session
|
||||
# (backup_full) when the longer job is not itself running (#6728 gate fix).
|
||||
_CRON_RUN_TS_RE = re.compile(r"\d{8}_\d{6}")
|
||||
|
||||
|
||||
_SESSIONS_CACHE_TTL_SECONDS = 2.5
|
||||
# #4808: while a turn is actively streaming the frontend polls /api/sessions on a
|
||||
# fixed cadence (static/sessions.js `_streamingPollMs`). With the idle TTL of
|
||||
@@ -35,6 +44,178 @@ _SESSIONS_CACHE_ALL_PROFILES_INVALIDATION_VERSION = 0
|
||||
_SESSIONS_CACHE_PROFILE_INVALIDATION_VERSION: dict[str, int] = {}
|
||||
|
||||
|
||||
_SIDEBAR_SESSION_RESPONSE_FIELDS = {
|
||||
"session_id",
|
||||
"title",
|
||||
"display_title",
|
||||
"_state_db_title",
|
||||
"workspace",
|
||||
"model",
|
||||
"model_provider",
|
||||
"message_count",
|
||||
"user_message_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_message_at",
|
||||
"pinned",
|
||||
"archived",
|
||||
"project_id",
|
||||
"profile",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"estimated_cost",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_hit_percent",
|
||||
"personality",
|
||||
"context_length",
|
||||
"config_context_length",
|
||||
"window_usage_percent",
|
||||
"source_tag",
|
||||
"raw_source",
|
||||
"session_source",
|
||||
"source_label",
|
||||
"is_cli_session",
|
||||
"is_messaging_session",
|
||||
"is_streaming",
|
||||
"cron_running",
|
||||
"active_stream_id",
|
||||
"has_pending_user_message",
|
||||
"pending_started_at",
|
||||
"default_hidden",
|
||||
"worktree_path",
|
||||
"worktree_branch",
|
||||
"parent_session_id",
|
||||
"parent_title",
|
||||
"parent_source",
|
||||
"relationship_type",
|
||||
"pre_compression_snapshot",
|
||||
"_lineage_root_id",
|
||||
"_lineage_tip_id",
|
||||
"_compression_segment_count",
|
||||
"_lineage_collapsed_count",
|
||||
"_parent_lineage_root_id",
|
||||
"_parent_lineage_tip_id",
|
||||
"_cross_surface_child_session",
|
||||
"match_type",
|
||||
"match_preview",
|
||||
# Preserved so the sidebar can suppress rename / action-menu / swipe on
|
||||
# read-only sessions and render the detailed gateway model label. Only the
|
||||
# latest bounded routing object is included; routing history stays excluded.
|
||||
"read_only",
|
||||
"is_read_only",
|
||||
"gateway_routing",
|
||||
}
|
||||
|
||||
|
||||
def _session_list_cache_sidebar_fields() -> set[str]:
|
||||
"""Return the canonical bounded field set used by the list response."""
|
||||
return _SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
|
||||
|
||||
def _session_list_cache_copy_value(value):
|
||||
"""Copy only mutable values after the transcript-bearing projection."""
|
||||
if isinstance(value, (dict, list, set)):
|
||||
return copy.deepcopy(value)
|
||||
return value
|
||||
|
||||
|
||||
def _session_list_cache_copy_row(row: dict) -> dict:
|
||||
return {
|
||||
key: _session_list_cache_copy_value(value)
|
||||
for key, value in row.items()
|
||||
}
|
||||
|
||||
|
||||
def _session_list_cache_bounded_payload(payload: dict) -> dict:
|
||||
"""Project a builder payload to data the sidebar can actually consume.
|
||||
|
||||
Builders operate on full session snapshots because they also perform
|
||||
reconciliation and lineage decisions. The cache must not retain those
|
||||
snapshots: a single long transcript can otherwise make every cache set/get
|
||||
allocate hundreds of megabytes via deepcopy().
|
||||
"""
|
||||
fields = _session_list_cache_sidebar_fields()
|
||||
|
||||
def project_rows(rows):
|
||||
projected = []
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
projected.append({})
|
||||
continue
|
||||
item = {key: row[key] for key in fields if key in row}
|
||||
projected.append(_session_list_cache_copy_row(item))
|
||||
return projected
|
||||
|
||||
bounded = {
|
||||
key: _session_list_cache_copy_value(value)
|
||||
for key, value in payload.items()
|
||||
if key not in {"sessions", "sidebar_reference_sessions"}
|
||||
}
|
||||
if "sessions" in payload:
|
||||
bounded["sessions"] = project_rows(payload.get("sessions"))
|
||||
if "sidebar_reference_sessions" in payload:
|
||||
bounded["sidebar_reference_sessions"] = project_rows(
|
||||
payload.get("sidebar_reference_sessions")
|
||||
)
|
||||
return bounded
|
||||
|
||||
|
||||
def _session_list_cache_copy_payload(payload: dict) -> dict:
|
||||
"""Copy only the already-bounded cache shape for request-local mutation."""
|
||||
copied = {
|
||||
key: _session_list_cache_copy_value(value)
|
||||
for key, value in payload.items()
|
||||
if key not in {"sessions", "sidebar_reference_sessions"}
|
||||
}
|
||||
if "sessions" in payload:
|
||||
copied["sessions"] = [
|
||||
_session_list_cache_copy_row(row)
|
||||
for row in payload.get("sessions", [])
|
||||
]
|
||||
if "sidebar_reference_sessions" in payload:
|
||||
copied["sidebar_reference_sessions"] = [
|
||||
_session_list_cache_copy_row(row)
|
||||
for row in payload.get("sidebar_reference_sessions", [])
|
||||
]
|
||||
return copied
|
||||
|
||||
|
||||
def get_session_list_cache_snapshot() -> dict[str, object]:
|
||||
"""Return scalar cache occupancy without waiting or changing LRU state.
|
||||
|
||||
Held-section discipline: between the nonblocking acquire and the release,
|
||||
only ``len()`` and module-constant reads are permitted. Nothing that can
|
||||
resolve config, resolve a profile, touch the filesystem, import a module, or
|
||||
wait on another lock may be added here. ``_SESSIONS_CACHE_LOCK`` is an
|
||||
``RLock``, so a nonblocking acquire from a thread already holding it would
|
||||
report available mid-mutation; the health collector is this helper's only
|
||||
caller and never runs nested inside a cache rebuild.
|
||||
"""
|
||||
result = {
|
||||
"available": False,
|
||||
"entries": 0,
|
||||
"inflight_rebuilds": 0,
|
||||
"cap": 0,
|
||||
}
|
||||
acquired = False
|
||||
try:
|
||||
acquired = _SESSIONS_CACHE_LOCK.acquire(blocking=False)
|
||||
if not acquired:
|
||||
return result
|
||||
return {
|
||||
"available": True,
|
||||
"entries": max(0, int(len(_SESSIONS_CACHE))),
|
||||
"inflight_rebuilds": max(0, int(len(_SESSIONS_CACHE_INFLIGHT))),
|
||||
"cap": max(0, int(_SESSIONS_CACHE_MAX_ENTRIES)),
|
||||
}
|
||||
except Exception:
|
||||
return result
|
||||
finally:
|
||||
if acquired:
|
||||
_SESSIONS_CACHE_LOCK.release()
|
||||
|
||||
|
||||
def _session_list_cache_session_dir() -> Path:
|
||||
try:
|
||||
import api.routes as _routes
|
||||
@@ -98,6 +279,27 @@ def _session_list_cache_active_stream_ids():
|
||||
return _active_stream_ids()
|
||||
|
||||
|
||||
def _session_list_cache_running_cron_jobs() -> dict[str, float]:
|
||||
"""Return {job_id: start_epoch} for cron jobs currently tracked as running.
|
||||
|
||||
Cron liveness lives only in the in-memory ``_RUNNING_CRON_JOBS`` dict in
|
||||
api.routes (#6728): the sidebar polls /api/sessions (not /api/crons/status),
|
||||
so without this overlay a still-running cron job's session row looks
|
||||
completed the moment it appends a message. Fail closed to an empty dict.
|
||||
"""
|
||||
try:
|
||||
import api.routes as _routes
|
||||
|
||||
jobs = getattr(_routes, "_RUNNING_CRON_JOBS", None)
|
||||
lock = getattr(_routes, "_RUNNING_CRON_LOCK", None)
|
||||
if jobs is None or lock is None:
|
||||
return {}
|
||||
with lock:
|
||||
return dict(jobs)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _session_list_cache_resolved_source_stamp(key: tuple):
|
||||
try:
|
||||
import api.routes as _routes
|
||||
@@ -127,6 +329,7 @@ def _session_list_cache_key(
|
||||
exclude_hidden: bool = False,
|
||||
visible_only: bool = False,
|
||||
show_webhook_sessions: bool = False,
|
||||
show_kanban_sessions: bool = False,
|
||||
source_filter: str | None = None,
|
||||
sidebar_source: str | None = None,
|
||||
archived_limit: int | None = None,
|
||||
@@ -152,6 +355,7 @@ def _session_list_cache_key(
|
||||
bool(exclude_hidden),
|
||||
bool(visible_only),
|
||||
bool(show_webhook_sessions),
|
||||
bool(show_kanban_sessions),
|
||||
source_filter,
|
||||
sidebar_source,
|
||||
normalized_archived_limit,
|
||||
@@ -173,7 +377,7 @@ def _session_list_cache_get(
|
||||
if stamp != current_stamp:
|
||||
if allow_stale:
|
||||
_SESSIONS_CACHE.move_to_end(key)
|
||||
return copy.deepcopy(payload), False
|
||||
return _session_list_cache_copy_payload(payload), False
|
||||
_SESSIONS_CACHE.pop(key, None)
|
||||
return None, False
|
||||
# #4808: widen the freshness window while a turn is streaming so the fixed
|
||||
@@ -184,10 +388,10 @@ def _session_list_cache_get(
|
||||
fresh = (now - ts) < ttl
|
||||
if fresh:
|
||||
_SESSIONS_CACHE.move_to_end(key)
|
||||
return copy.deepcopy(payload), True
|
||||
return _session_list_cache_copy_payload(payload), True
|
||||
if allow_stale:
|
||||
_SESSIONS_CACHE.move_to_end(key)
|
||||
return copy.deepcopy(payload), False
|
||||
return _session_list_cache_copy_payload(payload), False
|
||||
_SESSIONS_CACHE.pop(key, None)
|
||||
return None, False
|
||||
|
||||
@@ -211,15 +415,32 @@ def _session_list_cache_stale_reason(key: tuple) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _session_list_cache_set(key: tuple, payload: dict) -> None:
|
||||
def _session_list_cache_set(
|
||||
key: tuple,
|
||||
payload: dict,
|
||||
*,
|
||||
expected_invalidation_stamp: tuple[int, int] | None = None,
|
||||
) -> bool:
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
return False
|
||||
stamp = _session_list_cache_resolved_source_stamp(key)
|
||||
bounded = _session_list_cache_bounded_payload(payload)
|
||||
with _SESSIONS_CACHE_LOCK:
|
||||
_SESSIONS_CACHE[key] = (time.monotonic(), stamp, copy.deepcopy(payload))
|
||||
# Projection intentionally happens outside the lock so large source rows
|
||||
# cannot block cache hits. Re-check the caller's pre-build generation
|
||||
# atomically before insertion, otherwise a rename/archive/delete clear
|
||||
# that lands during projection can be undone by this stale write.
|
||||
if (
|
||||
expected_invalidation_stamp is not None
|
||||
and _session_list_cache_invalidation_stamp(key)
|
||||
!= expected_invalidation_stamp
|
||||
):
|
||||
return False
|
||||
_SESSIONS_CACHE[key] = (time.monotonic(), stamp, bounded)
|
||||
_SESSIONS_CACHE.move_to_end(key)
|
||||
while len(_SESSIONS_CACHE) > _SESSIONS_CACHE_MAX_ENTRIES:
|
||||
_SESSIONS_CACHE.popitem(last=False)
|
||||
return True
|
||||
|
||||
|
||||
def _session_list_cache_clear(profile: str | None = None) -> None:
|
||||
@@ -424,6 +645,11 @@ def _session_list_cache_overlay_runtime_rows(rows: list[dict]) -> list[dict]:
|
||||
active_stream_ids = _session_list_cache_active_stream_ids()
|
||||
except Exception:
|
||||
active_stream_ids = set()
|
||||
try:
|
||||
running_cron_jobs = _session_list_cache_running_cron_jobs()
|
||||
except Exception:
|
||||
running_cron_jobs = {}
|
||||
cron_job_prefixes = [(jid, f"cron_{jid}_", started_at) for jid, started_at in running_cron_jobs.items()]
|
||||
session_ids = [
|
||||
str(row.get("session_id") or "").strip()
|
||||
for row in rows
|
||||
@@ -455,11 +681,50 @@ def _session_list_cache_overlay_runtime_rows(rows: list[dict]) -> list[dict]:
|
||||
item[key] = raw_live_value
|
||||
stream_id = item.get("active_stream_id")
|
||||
item["is_streaming"] = bool(stream_id and stream_id in active_stream_ids)
|
||||
# #6728: a still-running cron job's session row must not look completed
|
||||
# in the sidebar. Cron liveness is only exposed via /api/crons/status,
|
||||
# which the sidebar never polls — stamp the flag here so the client can
|
||||
# defer its completion/unread transition until the job actually ends.
|
||||
# Session ids are cron_{job_id}_{run_timestamp}: only the run started at
|
||||
# (or after) the tracked start belongs to the live execution — older runs
|
||||
# of the same job stay completed.
|
||||
item["cron_running"] = _session_list_row_cron_running(
|
||||
sid, item, cron_job_prefixes
|
||||
)
|
||||
overlaid.append(item)
|
||||
overlaid.sort(key=_session_list_runtime_sort_key, reverse=True)
|
||||
return overlaid
|
||||
|
||||
|
||||
def _session_list_row_cron_running(
|
||||
sid: str, row: dict, cron_job_prefixes: list[tuple[str, str, float]]
|
||||
) -> bool:
|
||||
if not cron_job_prefixes or not sid:
|
||||
return False
|
||||
created_at = _session_list_row_numeric_value(row.get("created_at"))
|
||||
# Longest prefix first, no fall-through: job ids may nest (backup vs
|
||||
# backup_full), and the shorter prefix is a valid prefix of the longer one.
|
||||
# A session belongs to the longest matching job id — first-match in
|
||||
# insertion order, or falling through to a shorter prefix after a time-miss,
|
||||
# would let a running shorter-prefix job claim a completed longer-prefix
|
||||
# session. Mirrors the max(matches, key=len) convention in
|
||||
# api.routes._latest_cron_session_info_for_jobs.
|
||||
#
|
||||
# #6728 (gate fix): prefixes are built ONLY from RUNNING jobs, so if the
|
||||
# true longer owner (backup_full) is not running, longest-prefix sorting
|
||||
# never sees it and a running `backup` would otherwise swallow a
|
||||
# `cron_backup_full_YYYYMMDD_HHMMSS` session (the leftover `full_...` still
|
||||
# starts with nothing it should match). Require the text AFTER the prefix to
|
||||
# be exactly a run-timestamp (YYYYMMDD_HHMMSS) so a shorter job id cannot
|
||||
# claim a longer job id's session regardless of which jobs are running.
|
||||
for _jid, prefix, started_at in sorted(
|
||||
cron_job_prefixes, key=lambda item: len(item[1]), reverse=True
|
||||
):
|
||||
if sid.startswith(prefix) and _CRON_RUN_TS_RE.fullmatch(sid[len(prefix):]):
|
||||
return created_at >= started_at
|
||||
return False
|
||||
|
||||
|
||||
def _session_list_row_numeric_value(value) -> float:
|
||||
try:
|
||||
numeric = float(value or 0)
|
||||
|
||||
+3753
-1242
File diff suppressed because it is too large
Load Diff
+32
-6
@@ -36,7 +36,15 @@ _SEQ_CACHE_LOCK = threading.Lock()
|
||||
_SUMMARY_CACHE_MAX_ENTRIES = 128
|
||||
_SUMMARY_CACHE: OrderedDict[str, tuple[tuple[int, int, int, int, int], dict]] = OrderedDict()
|
||||
_SUMMARY_CACHE_LOCK = threading.Lock()
|
||||
_TERMINAL_SSE_EVENTS = {"done", "cancel", "apperror", "error", "stream_end"}
|
||||
# Events that mark a run terminal in the journal / summary sense.
|
||||
TERMINAL_SSE_EVENTS = frozenset({"done", "cancel", "apperror", "error", "stream_end"})
|
||||
# Events that should close an SSE relay drain loop. `done` is intentionally
|
||||
# excluded: background title generation and `stream_end` are emitted after
|
||||
# `done`, and breaking early would drop them. `apperror` is included because
|
||||
# it terminates with no trailing `stream_end`.
|
||||
SSE_RELAY_CLOSE_EVENTS = frozenset({"stream_end", "cancel", "apperror", "error"})
|
||||
# Back-compat alias used by older call sites / tests.
|
||||
_TERMINAL_SSE_EVENTS = TERMINAL_SSE_EVENTS
|
||||
_FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
|
||||
_FSYNC_MODE_EAGER = "eager"
|
||||
_FSYNC_MODE_TERMINAL_ONLY = "terminal-only"
|
||||
@@ -476,14 +484,32 @@ def read_run_events(
|
||||
}
|
||||
|
||||
|
||||
def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
|
||||
"""Return the terminal event that owns the run's settled outcome.
|
||||
|
||||
``stream_end`` is transport closure, so a preceding semantic terminal event
|
||||
(done, cancel, or error) remains authoritative. Among semantic terminal
|
||||
events, the latest journal row wins.
|
||||
"""
|
||||
terminal_events = [
|
||||
event
|
||||
for event in events
|
||||
if isinstance(event, dict) and event.get("terminal")
|
||||
]
|
||||
return next(
|
||||
(
|
||||
event
|
||||
for event in reversed(terminal_events)
|
||||
if event.get("event") != "stream_end"
|
||||
),
|
||||
terminal_events[-1] if terminal_events else None,
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
|
||||
ordered = [event for event in events if isinstance(event, dict)]
|
||||
last = ordered[-1] if ordered else None
|
||||
terminal_events = [event for event in ordered if event.get("terminal")]
|
||||
terminal = next(
|
||||
(event for event in reversed(terminal_events) if event.get("event") != "stream_end"),
|
||||
terminal_events[-1] if terminal_events else None,
|
||||
)
|
||||
terminal = select_authoritative_terminal_event(ordered)
|
||||
status = terminal.get("terminal_state") if terminal else ("running" if ordered else "unknown")
|
||||
return {
|
||||
"session_id": str(session_id),
|
||||
|
||||
@@ -17,6 +17,7 @@ import threading
|
||||
import shutil
|
||||
import sqlite3
|
||||
from collections import Counter
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
@@ -102,7 +103,7 @@ def _read_state_db(state_db_path: Path | None) -> dict[str, dict]:
|
||||
if state_db_path is None or not state_db_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
|
||||
with closing(sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
tables = {row[0] for row in conn.execute("select name from sqlite_master where type='table'")}
|
||||
if "sessions" not in tables:
|
||||
|
||||
+548
-16
@@ -8,17 +8,514 @@ the hermes-agent repo.
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import copy
|
||||
import hashlib
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from contextlib import nullcontext
|
||||
from bisect import bisect_left
|
||||
from typing import Any
|
||||
|
||||
from api.config import LOCK, _get_session_agent_lock
|
||||
from api.models import get_session, SESSIONS
|
||||
from api.agent_sessions import normalize_agent_session_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUTO_TITLE_LABELS = {'untitled', 'new chat'}
|
||||
|
||||
|
||||
class RegenerationUnavailable(Exception):
|
||||
def __init__(self, code: str, status: int = 409, message: str | None = None):
|
||||
super().__init__(message or code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def _regeneration_source_class(value):
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw == "fork":
|
||||
return "fork"
|
||||
normalized = normalize_agent_session_source(raw).get("session_source")
|
||||
return str(normalized or raw).strip().lower()
|
||||
|
||||
|
||||
def _regeneration_source_allowed(value):
|
||||
return _regeneration_source_class(value) in {"webui", "fork"}
|
||||
|
||||
|
||||
def _selected_regeneration_turn_owned(session, row) -> bool:
|
||||
"""Accept only a final row whose provenance proves WebUI ownership."""
|
||||
if getattr(session, "read_only", False) or not isinstance(row, dict):
|
||||
return False
|
||||
session_source = _regeneration_source_class(getattr(session, "session_source", None))
|
||||
imported_session = bool(
|
||||
getattr(session, "is_cli_session", False)
|
||||
or session_source not in {"", "webui", "fork"}
|
||||
)
|
||||
raw_sources = (
|
||||
getattr(session, "raw_source", None),
|
||||
getattr(session, "source_tag", None),
|
||||
)
|
||||
row_source = row.get("_source") or row.get("source")
|
||||
if session_source == "fork":
|
||||
if any(source and not _regeneration_source_allowed(source) for source in raw_sources):
|
||||
return False
|
||||
if row_source and not _regeneration_source_allowed(row_source):
|
||||
return False
|
||||
return bool(
|
||||
getattr(session, "parent_session_id", None)
|
||||
and row.get("_fork_child_turn") == getattr(session, "session_id", None)
|
||||
)
|
||||
if imported_session:
|
||||
token = row.get("_active_turn_token")
|
||||
if not isinstance(token, str) or not token.strip() or ":" not in token:
|
||||
return False
|
||||
stream_id, started_at = token.rsplit(":", 1)
|
||||
try:
|
||||
started = float(started_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
from api.process_event_utils import build_active_turn_token
|
||||
if not math.isfinite(started) or started <= 0:
|
||||
return False
|
||||
if build_active_turn_token(stream_id, started) != token:
|
||||
return False
|
||||
else:
|
||||
if any(source and not _regeneration_source_allowed(source) for source in raw_sources):
|
||||
return False
|
||||
if row_source and not _regeneration_source_allowed(row_source):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegenerationTurn:
|
||||
user_index: int
|
||||
assistant_index: int
|
||||
message: dict
|
||||
message_text: str
|
||||
attachments: list
|
||||
source: str
|
||||
message_count: int
|
||||
revision: str
|
||||
row_digest: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegenerationPlan:
|
||||
canonical_rows: list
|
||||
canonical_context: list
|
||||
turn: RegenerationTurn
|
||||
revision: str
|
||||
row_digest: str
|
||||
message_count: int
|
||||
truncation_boundary: int
|
||||
|
||||
|
||||
def plan_regeneration(session, *, expected_revision=None, lock_held=False):
|
||||
"""Prepare one canonical display/context pair for a locked regeneration."""
|
||||
lock_context = nullcontext() if lock_held else _get_session_agent_lock(session.session_id)
|
||||
with lock_context:
|
||||
rows, context = regeneration_state(session, use_sidecar=True)
|
||||
revision = regeneration_revision_for(rows, session=session, context=context)
|
||||
if expected_revision is not None and expected_revision != revision:
|
||||
raise RegenerationUnavailable("stale_regeneration_revision")
|
||||
turn = resolve_regeneration_turn(
|
||||
rows, session=session, expected_revision=revision,
|
||||
lock_held=True, context=context,
|
||||
)
|
||||
return RegenerationPlan(
|
||||
canonical_rows=copy.deepcopy(rows),
|
||||
canonical_context=copy.deepcopy(context),
|
||||
turn=turn,
|
||||
revision=revision,
|
||||
row_digest=turn.row_digest,
|
||||
message_count=len(rows),
|
||||
truncation_boundary=turn.user_index + 1,
|
||||
)
|
||||
|
||||
|
||||
def apply_regeneration_plan(
|
||||
session,
|
||||
plan: RegenerationPlan,
|
||||
*,
|
||||
return_context_user: bool = False,
|
||||
):
|
||||
"""Install the prepared pair and truncate it without a second authority read."""
|
||||
def _result(success, context_user=None):
|
||||
return (success, context_user) if return_context_user else success
|
||||
|
||||
if not isinstance(plan, RegenerationPlan):
|
||||
return _result(False)
|
||||
rows = copy.deepcopy(plan.canonical_rows)
|
||||
context = copy.deepcopy(plan.canonical_context)
|
||||
if len(rows) != plan.message_count or plan.truncation_boundary != plan.turn.user_index + 1:
|
||||
return _result(False)
|
||||
if regeneration_revision_for(rows, session=session, context=context) != plan.revision:
|
||||
return _result(False)
|
||||
session.messages = rows
|
||||
session.context_messages = context
|
||||
current = session.messages[plan.turn.user_index]
|
||||
if not isinstance(current, dict) or current.get("role") != "user":
|
||||
return _result(False)
|
||||
truncate_session_at_keep(session, plan.truncation_boundary)
|
||||
prepared_context, context_boundary_index = truncate_context_for_display_keep(
|
||||
context,
|
||||
rows,
|
||||
plan.truncation_boundary,
|
||||
return_boundary_index=True,
|
||||
)
|
||||
session.context_messages = prepared_context if prepared_context or not context else context[: plan.truncation_boundary]
|
||||
retained_context_user = None
|
||||
if context_boundary_index is not None:
|
||||
for context_row in reversed(session.context_messages[: context_boundary_index + 1]):
|
||||
if isinstance(context_row, dict) and context_row.get("role") == "user":
|
||||
retained_context_user = context_row
|
||||
break
|
||||
return _result(True, retained_context_user)
|
||||
|
||||
|
||||
def snapshot_regeneration_state(session):
|
||||
return copy.deepcopy(session.__dict__)
|
||||
|
||||
|
||||
def restore_regeneration_state(session, snapshot):
|
||||
session.__dict__.clear()
|
||||
session.__dict__.update(copy.deepcopy(snapshot))
|
||||
|
||||
|
||||
def regeneration_revision_for(rows, *, session=None, context=None) -> str:
|
||||
"""Hash the canonical writable transcript and its aligned context."""
|
||||
payload = json.dumps(
|
||||
{
|
||||
"session_id": str(getattr(session, "session_id", "") or "") if session is not None else "",
|
||||
"messages": list(rows or []),
|
||||
"context_messages": list(context or []),
|
||||
"truncation_watermark": getattr(session, "truncation_watermark", None) if session is not None else None,
|
||||
"truncation_boundary": getattr(session, "truncation_boundary", None) if session is not None else None,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def regeneration_transcript(session, *, state_messages=None):
|
||||
"""Return the state.db-reconciled transcript used by every authority consumer."""
|
||||
if state_messages is None:
|
||||
return regeneration_state(session)[0]
|
||||
from api.models import reconciled_state_db_messages_for_session
|
||||
return reconciled_state_db_messages_for_session(session, state_messages=state_messages)
|
||||
|
||||
|
||||
def regeneration_context(session):
|
||||
return regeneration_state(session)[1]
|
||||
|
||||
|
||||
_REGENERATION_SIDECAR_ANCHOR_BUDGET = 200
|
||||
|
||||
|
||||
def _sidecar_regeneration_read_floor(session):
|
||||
"""Return a state.db tail-read floor anchored by the already-loaded sidecar.
|
||||
|
||||
#6826: regenerating a large session must not re-materialize the full
|
||||
state.db transcript (a >1min stall on big sessions). When the in-memory
|
||||
sidecar is a usable reconciliation base — append-only session with no
|
||||
active truncation markers and timestamped rows — return the timestamp
|
||||
floor for a bounded ``since_timestamp`` tail read. Rows at/after the
|
||||
floor (including any gateway/server-applied tail the sidecar has not seen
|
||||
yet) are re-read and merged, and rows the sidecar already carries are
|
||||
deduplicated by the append-only merge, so the #6611 reconciliation
|
||||
authority is preserved on the fast path.
|
||||
|
||||
Returns ``None`` when the sidecar cannot anchor a tail read; callers then
|
||||
fall back to the full state.db read (unchanged behavior).
|
||||
"""
|
||||
if getattr(session, "truncation_watermark", None) not in (None, ""):
|
||||
return None
|
||||
if getattr(session, "truncation_boundary", None) not in (None, ""):
|
||||
return None
|
||||
messages = getattr(session, "messages", None)
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return None
|
||||
from api.models import _message_timestamp_as_float
|
||||
|
||||
timestamps = [_message_timestamp_as_float(message) for message in messages]
|
||||
if any(timestamp is None for timestamp in timestamps):
|
||||
return None
|
||||
# Conservative anchor: re-read a bounded tip window so sub-second/clock
|
||||
# drift near the sidecar tip cannot hide a concurrently appended state.db
|
||||
# row, while the raw read stays tiny for huge sessions.
|
||||
return min(timestamps[-_REGENERATION_SIDECAR_ANCHOR_BUDGET:])
|
||||
|
||||
|
||||
def _bounded_tail_snapshot_if_safe(session, read_floor):
|
||||
"""Return the bounded tail rows ONLY when it is provably identical to the
|
||||
full read; otherwise None (caller must fall back to the full read).
|
||||
|
||||
#6826 r3: the skipped state.db prefix (rows older than the floor) must be
|
||||
represented identically in the sidecar — same count AND same ordered
|
||||
visible identity — and the bounded tail must not repeat any skipped key
|
||||
(occurrence-count collision: a new tail turn repeating an older prompt
|
||||
would be mistaken for the old sidecar duplicate and dropped). The prefix
|
||||
proof and the tail data come from ONE read transaction (no TOCTOU).
|
||||
|
||||
Any mismatch, missing database, or uncertainty returns None, so the #6611
|
||||
regeneration authority never operates on an unreconciled view.
|
||||
"""
|
||||
sid = getattr(session, "session_id", None)
|
||||
if not sid:
|
||||
return None
|
||||
profile = getattr(session, "profile", None)
|
||||
from api.models import (
|
||||
_session_message_visible_key,
|
||||
get_state_db_regeneration_tail_snapshot,
|
||||
)
|
||||
|
||||
snap = get_state_db_regeneration_tail_snapshot(sid, read_floor, profile=profile)
|
||||
if snap is None:
|
||||
return None # cannot obtain a stable single-snapshot → full read
|
||||
# Compression-anchor coverage: if the anchor predates the floor the bounded
|
||||
# read can drop compacted-tail context rows (display may still match).
|
||||
anchor = getattr(session, "compression_anchor_message_key", None)
|
||||
if isinstance(anchor, dict):
|
||||
try:
|
||||
anchor_ts = float(anchor.get("ts"))
|
||||
except (TypeError, ValueError):
|
||||
anchor_ts = None
|
||||
if anchor_ts is None or anchor_ts < read_floor:
|
||||
return None
|
||||
prefix = snap["prefix"]
|
||||
if prefix.get("count") == 0 and prefix.get("null_timestamp_count") == 0:
|
||||
# Empty skipped prefix: the bounded read already covers every row.
|
||||
return snap["tail"]
|
||||
# Non-empty skipped prefix: prove identical ordered visible identity.
|
||||
sidecar_keys = []
|
||||
for message in getattr(session, "messages", None) or []:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
try:
|
||||
ts = float(message.get("timestamp"))
|
||||
except (TypeError, ValueError):
|
||||
ts = None
|
||||
if ts is not None and ts < read_floor:
|
||||
key = _session_message_visible_key(message)
|
||||
if key is None:
|
||||
return None
|
||||
sidecar_keys.append(key)
|
||||
if list(snap["prefix_keys"]) != sidecar_keys:
|
||||
return None # mismatch → full read
|
||||
# Occurrence-count collision (#6826 r3 #1): if any bounded-tail key ALSO
|
||||
# occurs in the skipped prefix, the reconciler may drop the repeated tail
|
||||
# row — fall back conservatively.
|
||||
prefix_key_set = set(snap["prefix_keys"])
|
||||
for key in snap["tail_keys"]:
|
||||
if key in prefix_key_set:
|
||||
return None
|
||||
# In-tail duplicates (#6826 r5): a repeated message wholly inside the
|
||||
# bounded tail makes the reconciler's context dedup diverge from the full
|
||||
# read (display may still match) → refuse the bounded path.
|
||||
if len(snap["tail_keys"]) != len(set(snap["tail_keys"])):
|
||||
return None
|
||||
return snap["tail"]
|
||||
|
||||
|
||||
def regeneration_state(session, *, use_sidecar=False):
|
||||
"""Read one immutable state.db snapshot and reconcile both transcript views.
|
||||
|
||||
``use_sidecar=True`` (#6826) anchors the state.db read to the already
|
||||
loaded in-memory sidecar: only a bounded tail (``since_timestamp`` floor)
|
||||
is re-read instead of the full transcript, and both views still route
|
||||
through :func:`reconciled_state_db_messages_for_session`, so the #6611
|
||||
reconciliation authority (recovered display/context pair survives local
|
||||
and gateway apply) is preserved on the fast path.
|
||||
|
||||
The bounded tail is only trusted when
|
||||
:func:`_bounded_tail_snapshot_if_safe` proves the skipped state.db prefix
|
||||
is identical in the sidecar (count + ordered visible identity + no
|
||||
occurrence collision + compression anchor coverage), and the tail rows
|
||||
come from the SAME single read transaction as the proof (no TOCTOU);
|
||||
otherwise the read falls back to the full transcript.
|
||||
"""
|
||||
from api.models import (
|
||||
get_state_db_session_messages,
|
||||
reconciled_state_db_messages_for_session,
|
||||
)
|
||||
|
||||
bounded_tail = None
|
||||
if use_sidecar:
|
||||
read_floor = _sidecar_regeneration_read_floor(session)
|
||||
if read_floor is not None:
|
||||
bounded_tail = _bounded_tail_snapshot_if_safe(session, read_floor)
|
||||
if bounded_tail is not None:
|
||||
state_messages = bounded_tail
|
||||
else:
|
||||
state_messages = get_state_db_session_messages(
|
||||
getattr(session, "session_id", None),
|
||||
profile=getattr(session, "profile", None),
|
||||
)
|
||||
return (
|
||||
reconciled_state_db_messages_for_session(
|
||||
session,
|
||||
state_messages=state_messages,
|
||||
),
|
||||
reconciled_state_db_messages_for_session(
|
||||
session,
|
||||
prefer_context=True,
|
||||
state_messages=state_messages,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def regeneration_revision(session) -> str:
|
||||
rows, context = regeneration_state(session, use_sidecar=True)
|
||||
return regeneration_revision_for(
|
||||
rows,
|
||||
session=session,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def regeneration_authority(
|
||||
session,
|
||||
rows=None,
|
||||
*,
|
||||
context=None,
|
||||
full_transcript=True,
|
||||
canonical_state=None,
|
||||
):
|
||||
"""Mint a revision only for a complete, writable, canonical transcript."""
|
||||
if not full_transcript:
|
||||
return None
|
||||
if getattr(session, "active_stream_id", None) or getattr(session, "pending_user_message", None):
|
||||
return None
|
||||
canonical_rows, canonical_context = canonical_state or regeneration_state(session)
|
||||
rows = list(canonical_rows if rows is None else rows)
|
||||
if not rows:
|
||||
return None
|
||||
if rows != canonical_rows:
|
||||
return None
|
||||
if context is not None and list(context or []) != canonical_context:
|
||||
return None
|
||||
try:
|
||||
resolve_regeneration_turn(
|
||||
canonical_rows,
|
||||
session=session,
|
||||
context=canonical_context,
|
||||
)
|
||||
except RegenerationUnavailable:
|
||||
return None
|
||||
return regeneration_revision_for(
|
||||
canonical_rows,
|
||||
session=session,
|
||||
context=canonical_context,
|
||||
)
|
||||
|
||||
|
||||
def resolve_regeneration_turn(
|
||||
rows,
|
||||
*,
|
||||
session=None,
|
||||
expected_revision=None,
|
||||
lock_held=False,
|
||||
context=None,
|
||||
):
|
||||
"""Select the current session's final complete local exchange under its lock."""
|
||||
legacy_session_call = session is None and not isinstance(rows, (list, tuple))
|
||||
legacy_context = None
|
||||
if legacy_session_call:
|
||||
session = rows
|
||||
rows, legacy_context = regeneration_state(session)
|
||||
lock_context = (
|
||||
_get_session_agent_lock(session.session_id)
|
||||
if legacy_session_call and not lock_held
|
||||
else nullcontext()
|
||||
)
|
||||
with lock_context:
|
||||
rows = list(rows or [])
|
||||
if context is None:
|
||||
context = legacy_context
|
||||
if context is None:
|
||||
_, context = regeneration_state(session)
|
||||
context = list(context)
|
||||
revision = regeneration_revision_for(rows, session=session, context=context)
|
||||
if expected_revision is not None and expected_revision != revision:
|
||||
raise RegenerationUnavailable("stale_regeneration_revision")
|
||||
if getattr(session, "active_stream_id", None):
|
||||
raise RegenerationUnavailable("session_active")
|
||||
if getattr(session, "pending_user_message", None):
|
||||
raise RegenerationUnavailable("session_active")
|
||||
assistant_index = next(
|
||||
(
|
||||
index
|
||||
for index in range(len(rows) - 1, -1, -1)
|
||||
if isinstance(rows[index], dict)
|
||||
and rows[index].get("role") == "assistant"
|
||||
and _assistant_message_has_final_visible_text(rows[index])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if assistant_index is not None:
|
||||
index = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in range(assistant_index - 1, -1, -1)
|
||||
if isinstance(rows[candidate], dict)
|
||||
and rows[candidate].get("role") == "user"
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
index = None
|
||||
if index is not None:
|
||||
if any(
|
||||
isinstance(row, dict) and row.get("role") == "user"
|
||||
for row in rows[assistant_index + 1:]
|
||||
):
|
||||
raise RegenerationUnavailable("no_regenerable_turn", 400)
|
||||
if any(
|
||||
isinstance(row, dict) and row.get("role") in {"assistant", "tool"}
|
||||
for row in rows[assistant_index + 1:]
|
||||
):
|
||||
raise RegenerationUnavailable("no_regenerable_turn", 400)
|
||||
row = rows[index]
|
||||
if not _selected_regeneration_turn_owned(session, row):
|
||||
raise RegenerationUnavailable("regeneration_read_only", 403)
|
||||
content = _extract_text(row.get("content", ""))
|
||||
if content:
|
||||
row_digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
row,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return RegenerationTurn(
|
||||
index,
|
||||
assistant_index,
|
||||
copy.deepcopy(row),
|
||||
content,
|
||||
copy.deepcopy(row.get("attachments") or []),
|
||||
str(row.get("_source") or "webui"),
|
||||
len(rows),
|
||||
revision,
|
||||
row_digest,
|
||||
)
|
||||
raise RegenerationUnavailable("no_regenerable_turn", 400)
|
||||
|
||||
|
||||
def _assistant_message_has_final_visible_text(message) -> bool:
|
||||
from api.streaming import _assistant_message_has_final_visible_text as _has_final_text
|
||||
|
||||
return _has_final_text(message)
|
||||
|
||||
|
||||
def _live_active_stream_id(session) -> str | None:
|
||||
"""Return session.active_stream_id ONLY if that stream is live in THIS
|
||||
process; else None.
|
||||
@@ -29,19 +526,26 @@ def _live_active_stream_id(session) -> str | None:
|
||||
the hidden-tab poller) would make a client attach its renderer to a stream
|
||||
that never emits — a permanent fake "thinking" state. Liveness test mirrors
|
||||
routes._clear_stale_stream_state: live iff present in STREAMS (open SSE
|
||||
channel) or ACTIVE_RUNS (worker bookkeeping).
|
||||
channel) or ACTIVE_RUNS (worker bookkeeping) — except that a
|
||||
``phase="cancelling"`` run is excluded on both paths, because the worker may
|
||||
still be unwinding while the client already reached a terminal state for
|
||||
that stream.
|
||||
"""
|
||||
stream_id = getattr(session, 'active_stream_id', None)
|
||||
if not stream_id:
|
||||
return None
|
||||
try:
|
||||
from api import config as _cfg
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
_active_run_present = stream_id in (_cfg.ACTIVE_RUNS or {})
|
||||
_active_run_entry = (_cfg.ACTIVE_RUNS or {}).get(stream_id)
|
||||
if _active_run_present and not _cfg.active_run_is_attachable(_active_run_entry):
|
||||
return None
|
||||
with _cfg.STREAMS_LOCK:
|
||||
if stream_id in _cfg.STREAMS:
|
||||
return stream_id
|
||||
with _cfg.ACTIVE_RUNS_LOCK:
|
||||
if stream_id in (_cfg.ACTIVE_RUNS or {}):
|
||||
return stream_id
|
||||
if _active_run_present:
|
||||
return stream_id
|
||||
except Exception:
|
||||
# On any introspection failure, fail SAFE (report no live stream) rather
|
||||
# than surfacing a possibly-stale id.
|
||||
@@ -99,20 +603,33 @@ def _truncation_watermark_for(messages):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _stamp_intentional_shrink_generation(session, old_message_count: int, new_message_count: int) -> bool:
|
||||
"""Stamp a new generation only when the visible message list shrinks."""
|
||||
if new_message_count >= old_message_count:
|
||||
return False
|
||||
session.intentional_shrink_generation = uuid.uuid4().hex
|
||||
return True
|
||||
|
||||
|
||||
def truncate_context_for_display_keep(
|
||||
context_messages: list | None,
|
||||
full_messages: list | None,
|
||||
keep: int,
|
||||
*,
|
||||
return_boundary_index: bool = False,
|
||||
) -> list:
|
||||
"""Align model context with display prefix ``full_messages[:keep]``."""
|
||||
def _result(rows, boundary_index=None):
|
||||
return (rows, boundary_index) if return_boundary_index else rows
|
||||
|
||||
if keep <= 0:
|
||||
return []
|
||||
return _result([])
|
||||
ctx = context_messages if isinstance(context_messages, list) else []
|
||||
msgs = full_messages if isinstance(full_messages, list) else []
|
||||
if not ctx:
|
||||
return []
|
||||
return _result([])
|
||||
if len(msgs) == 0:
|
||||
return []
|
||||
return _result([])
|
||||
# Only the perfectly-parallel case (display and context row-for-row) can be
|
||||
# sliced at the raw display index. When the two arrays differ in length —
|
||||
# in EITHER direction — they have diverged and need alignment:
|
||||
@@ -129,7 +646,7 @@ def truncate_context_for_display_keep(
|
||||
# strips unanswered tool_calls; gateway: it forwards no tool_calls/tool rows
|
||||
# at all), so we do not re-do that trimming here.
|
||||
if len(ctx) == len(msgs):
|
||||
return ctx[:keep]
|
||||
return _result(ctx[:keep], min(keep, len(ctx)) - 1)
|
||||
|
||||
def _row_signature(row: Any) -> tuple[str, ...] | None:
|
||||
if not isinstance(row, dict):
|
||||
@@ -358,8 +875,8 @@ def truncate_context_for_display_keep(
|
||||
and isinstance(msgs[keep - 1], dict)
|
||||
and msgs[keep - 1].get('role') == 'user'
|
||||
):
|
||||
return ctx[:last_kept + 1]
|
||||
return ctx[:first_unkept]
|
||||
return _result(ctx[:last_kept + 1], last_kept)
|
||||
return _result(ctx[:first_unkept], first_unkept - 1)
|
||||
if last_kept is not None:
|
||||
ambiguous_first_unkept = ambiguous_matches[keep]
|
||||
if (
|
||||
@@ -367,8 +884,8 @@ def truncate_context_for_display_keep(
|
||||
and isinstance(msgs[keep - 1], dict)
|
||||
and msgs[keep - 1].get('role') != 'user'
|
||||
):
|
||||
return ctx[:ambiguous_first_unkept]
|
||||
return ctx[:last_kept + 1]
|
||||
return _result(ctx[:ambiguous_first_unkept], ambiguous_first_unkept - 1)
|
||||
return _result(ctx[:last_kept + 1], last_kept)
|
||||
|
||||
# Both boundary rows were ambiguous/unmatched (common in large sessions
|
||||
# where context rows have lost their id/timestamp so the matcher can't
|
||||
@@ -387,14 +904,15 @@ def truncate_context_for_display_keep(
|
||||
for i in range(keep - 1, -1, -1):
|
||||
resolved = matches[i] if matches[i] is not None else ambiguous_matches[i]
|
||||
if resolved is not None:
|
||||
return ctx[:resolved + 1]
|
||||
return _result(ctx[:resolved + 1], resolved)
|
||||
|
||||
# Final fallback preserves #5096 behavior when alignment is unreliable
|
||||
# (no display row resolved to a context index, or keep >= len(msgs)).
|
||||
prefix_len = max(0, len(ctx) - len(msgs))
|
||||
prefix = ctx[:prefix_len]
|
||||
suffix = ctx[prefix_len:]
|
||||
return prefix + suffix[:keep]
|
||||
result = prefix + suffix[:keep]
|
||||
return _result(result, len(result) - 1 if result else None)
|
||||
|
||||
|
||||
def truncate_session_at_keep(session, keep: int) -> tuple[int, int]:
|
||||
@@ -403,6 +921,7 @@ def truncate_session_at_keep(session, keep: int) -> tuple[int, int]:
|
||||
old_msg_count = len(full_messages)
|
||||
old_ctx_count = len(getattr(session, 'context_messages', None) or [])
|
||||
session.messages = full_messages[:keep]
|
||||
_stamp_intentional_shrink_generation(session, old_msg_count, len(session.messages))
|
||||
if isinstance(getattr(session, 'context_messages', None), list):
|
||||
session.context_messages = truncate_context_for_display_keep(
|
||||
session.context_messages,
|
||||
@@ -462,6 +981,7 @@ def retry_last(session_id: str) -> dict[str, Any]:
|
||||
last_user_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
_stamp_intentional_shrink_generation(s, len(history), len(s.messages))
|
||||
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
||||
# Persist the original truncate cutoff so empty-sidecar recovery
|
||||
# can distinguish legitimate prefix from deleted suffix.
|
||||
@@ -505,6 +1025,7 @@ def undo_last(session_id: str) -> dict[str, Any]:
|
||||
removed_text = _extract_text(history[last_user_idx].get('content', ''))
|
||||
removed_count = len(history) - last_user_idx
|
||||
s.messages = history[:last_user_idx]
|
||||
_stamp_intentional_shrink_generation(s, len(history), len(s.messages))
|
||||
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
||||
# Persist the original truncate cutoff.
|
||||
s.truncation_boundary = s.truncation_watermark
|
||||
@@ -599,7 +1120,18 @@ def _extract_text(content: Any) -> str:
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for p in content:
|
||||
if isinstance(p, dict) and p.get('type') == 'text':
|
||||
parts.append(p.get('text', ''))
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
part_type = str(p.get('type') or '').lower()
|
||||
if part_type not in ('', 'text', 'input_text', 'output_text'):
|
||||
continue
|
||||
part_text = (
|
||||
p.get('text')
|
||||
or p.get('content')
|
||||
or p.get('input_text')
|
||||
or p.get('output_text')
|
||||
or ''
|
||||
)
|
||||
parts.append(str(part_text))
|
||||
return ' '.join(parts)
|
||||
return str(content)
|
||||
|
||||
+60
-2
@@ -29,9 +29,11 @@ import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from api.turn_journal import (
|
||||
@@ -43,6 +45,24 @@ from api.turn_journal import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTENTIONAL_SHRINK_GENERATION_RE = re.compile(
|
||||
r"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$"
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_intentional_shrink_generation(value) -> bool:
|
||||
"""Return whether *value* has the exact ``uuid.uuid4().hex`` shape.
|
||||
|
||||
``session_ops`` stamps generations with ``uuid.uuid4().hex``. Recovery
|
||||
must not treat arbitrary non-empty strings as provenance, because a
|
||||
malformed live/backup marker must leave the original restore safeguard
|
||||
enabled.
|
||||
"""
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and _INTENTIONAL_SHRINK_GENERATION_RE.fullmatch(value) is not None
|
||||
)
|
||||
|
||||
|
||||
def _msg_count(p: Path) -> int:
|
||||
"""Return the number of messages in a session JSON file, or -1 on read/parse error.
|
||||
@@ -283,6 +303,36 @@ def _live_supersedes_backup_by_clear_generation(session_path: Path, bak_path: Pa
|
||||
return True
|
||||
|
||||
|
||||
def _session_records_intentional_message_shrink(session_path: Path, bak_path: Path) -> bool:
|
||||
"""Return True when a live shrink generation supersedes an older backup.
|
||||
|
||||
The marker is deliberately narrow: an unreadable, malformed, or otherwise
|
||||
invalid marker must leave the existing data-loss recovery path enabled.
|
||||
"""
|
||||
try:
|
||||
live = json.loads(session_path.read_text(encoding='utf-8'))
|
||||
bak = json.loads(bak_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(live, dict) or not isinstance(bak, dict):
|
||||
return False
|
||||
live_generation = live.get('intentional_shrink_generation')
|
||||
if not _is_valid_intentional_shrink_generation(live_generation):
|
||||
return False
|
||||
if 'intentional_shrink_generation' not in bak:
|
||||
return True
|
||||
backup_generation = bak.get('intentional_shrink_generation')
|
||||
# Older backups persist the field as null (or omit it entirely) because
|
||||
# the live session had not yet passed through a session_ops shrink. Keep
|
||||
# that legacy provenance behavior; malformed non-empty values below must
|
||||
# fail open to the original restore path.
|
||||
if backup_generation is None:
|
||||
return True
|
||||
if not _is_valid_intentional_shrink_generation(backup_generation):
|
||||
return False
|
||||
return backup_generation != live_generation
|
||||
|
||||
|
||||
def inspect_session_recovery_status(session_path: Path) -> dict:
|
||||
"""Return a status dict describing whether recovery is recommended.
|
||||
|
||||
@@ -326,6 +376,14 @@ def inspect_session_recovery_status(session_path: Path) -> dict:
|
||||
"recommend": "no_action",
|
||||
"intentional_compress_shrink": True,
|
||||
}
|
||||
if _session_records_intentional_message_shrink(session_path, bak_path):
|
||||
return {
|
||||
"session_id": session_path.stem,
|
||||
"live_messages": live_count,
|
||||
"bak_messages": bak_count,
|
||||
"recommend": "no_action",
|
||||
"intentional_message_shrink": True,
|
||||
}
|
||||
return {
|
||||
"session_id": session_path.stem,
|
||||
"live_messages": live_count,
|
||||
@@ -381,7 +439,7 @@ def _state_db_has_session(session_id: str, state_db_path: Path | None) -> bool:
|
||||
if state_db_path is None or not state_db_path.exists():
|
||||
return True
|
||||
try:
|
||||
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
|
||||
with closing(sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True)) as conn:
|
||||
cur = conn.execute(
|
||||
"select 1 from sqlite_master where type='table' and name='sessions'"
|
||||
)
|
||||
@@ -446,7 +504,7 @@ def _read_state_db_missing_sidecar_rows(
|
||||
if state_db_path is None or not state_db_path.exists():
|
||||
return []
|
||||
try:
|
||||
with sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True) as conn:
|
||||
with closing(sqlite3.connect(f"file:{state_db_path}?mode=ro", uri=True)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
session_cols = {row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()}
|
||||
message_cols = {row[1] for row in conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||
|
||||
@@ -195,3 +195,57 @@ def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
|
||||
|
||||
def sync_session_title(session_id: str, title: str, profile: Optional[str] = None) -> None:
|
||||
"""Sync an auto-generated title to state.db (not gated by sync_to_insights).
|
||||
|
||||
Background title generation writes the title to the WebUI sidecar JSON but
|
||||
not to hermes-agent's state.db, so ``hermes sessions list`` shows blank
|
||||
titles for WebUI sessions. This function bridges that gap and is called
|
||||
from the background title update/refresh paths after a title is persisted.
|
||||
|
||||
Uses ``set_auto_title`` (LLM provenance) so it will only populate a row that
|
||||
is NULL or holds a lower-authority auto-title, and never overwrites a manual
|
||||
rename made via CLI/Gateway/TUI (``set_auto_title`` returns ``False``,
|
||||
untouched, when a higher-authority title holds the row). This means title
|
||||
refreshes (where state.db already holds the initial auto-title) are
|
||||
effectively no-ops at the state.db layer -- acceptable because the primary
|
||||
goal is ensuring ``hermes sessions list`` is not blank.
|
||||
|
||||
On a title collision (two sessions with the same auto-title), the title is
|
||||
de-duplicated via ``get_next_title_in_lineage`` (e.g. "My Session" ->
|
||||
"My Session #2") and retried, so the second session is never left blank.
|
||||
"""
|
||||
if not title:
|
||||
return
|
||||
db = _get_state_db(profile=profile)
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
# Ensure the session row exists (idempotent) so the UPDATE has a target.
|
||||
db.ensure_session(session_id=session_id, source='webui')
|
||||
# hermes-agent's SessionDB.set_auto_title_if_empty was renamed to
|
||||
# set_auto_title(session_id, title, *, source) in the state-module
|
||||
# split (agent commit 53db597201, released v2026.9.7). set_auto_title
|
||||
# preserves the same "only populate NULL / never clobber a manual
|
||||
# rename" semantics (returns False, untouched, when a higher-authority
|
||||
# title holds the row) and requires an explicit auto source.
|
||||
_llm_source = getattr(db, "TITLE_SOURCE_LLM", "llm")
|
||||
try:
|
||||
db.set_auto_title(session_id, title, source=_llm_source)
|
||||
except ValueError:
|
||||
# state.db enforces uniqueness on sessions.title, so a byte-identical
|
||||
# auto-title generated for two sessions raises ValueError here. Derive
|
||||
# a de-duplicated variant (e.g. "My Session" -> "My Session #2") and
|
||||
# retry instead of leaving the second row blank (#6964).
|
||||
alt = db.get_next_title_in_lineage(title)
|
||||
if alt and alt != title:
|
||||
db.set_auto_title(session_id, alt, source=_llm_source)
|
||||
except Exception:
|
||||
logger.debug("Failed to sync session title to state.db for %s", session_id)
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.debug("Failed to close state.db")
|
||||
|
||||
+2553
-374
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
"""Dependency-light helpers for launching child processes consistently."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def windows_hide_flags() -> int:
|
||||
"""Hide a short-lived console child on Win32 and remain a POSIX no-op.
|
||||
|
||||
``CREATE_NO_WINDOW`` keeps captured stdout and stderr connected, unlike
|
||||
detaching the process. Passing ``0`` elsewhere preserves the subprocess
|
||||
default. See #5692.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
return 0
|
||||
@@ -159,6 +159,113 @@ def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
|
||||
return {"metric": metric, "code": type(exc).__name__}
|
||||
|
||||
|
||||
def _zero_webui_runtime_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"sessions": {"available": False, "resident": 0, "cap": 0},
|
||||
"streams": {
|
||||
"available": False, "active": 0, "agent_instances": 0,
|
||||
"subscribers": 0, "offline_buffered_events": 0,
|
||||
"offline_dropped_events": 0, "subscriber_dropped_events": 0,
|
||||
"unavailable_channels": 0,
|
||||
},
|
||||
"session_list_cache": {
|
||||
"available": False, "entries": 0, "inflight_rebuilds": 0, "cap": 0,
|
||||
},
|
||||
"models_cache": {
|
||||
"available": False, "groups": 0, "models": 0, "age_seconds": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _safe_runtime_int(value: Any) -> int:
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _webui_runtime_payload(errors: list[dict[str, str]]) -> dict[str, Any]:
|
||||
runtime = _zero_webui_runtime_payload()
|
||||
# The three owner modules stay lazily imported so this small diagnostics
|
||||
# module cannot create an eager import cycle. The import-lock wait that
|
||||
# implies is only reachable while a first import is in flight, and
|
||||
# api/routes.py imports all three at load, before any request can dispatch.
|
||||
try:
|
||||
from api import config
|
||||
except Exception as exc:
|
||||
config = None
|
||||
config_import_error = exc
|
||||
else:
|
||||
config_import_error = None
|
||||
try:
|
||||
from api import route_session_list_cache
|
||||
except Exception as exc:
|
||||
route_session_list_cache = None
|
||||
cache_import_error = exc
|
||||
else:
|
||||
cache_import_error = None
|
||||
try:
|
||||
from api import streaming
|
||||
except Exception as exc:
|
||||
streaming = None
|
||||
streaming_import_error = exc
|
||||
else:
|
||||
streaming_import_error = None
|
||||
|
||||
# Collect the config owner exactly once per payload: two calls would double
|
||||
# the blocking edge and let `sessions` and `models_cache` come from two
|
||||
# different snapshots of the same process.
|
||||
config_snapshot: Any = None
|
||||
config_error = config_import_error
|
||||
if config is not None and config_error is None:
|
||||
try:
|
||||
config_snapshot = config.get_runtime_diagnostics_snapshot()
|
||||
except Exception as exc:
|
||||
config_error = exc
|
||||
|
||||
def _config_section(section: str):
|
||||
def project():
|
||||
if not isinstance(config_snapshot, dict):
|
||||
raise TypeError("runtime_snapshot_invalid")
|
||||
return config_snapshot[section]
|
||||
|
||||
return project
|
||||
|
||||
sources = {
|
||||
"sessions": (_config_section("sessions"), config_error),
|
||||
"models_cache": (_config_section("models_cache"), config_error),
|
||||
"streams": (
|
||||
(lambda: streaming.get_stream_runtime_snapshot())
|
||||
if streaming is not None else None,
|
||||
streaming_import_error,
|
||||
),
|
||||
"session_list_cache": (
|
||||
(lambda: route_session_list_cache.get_session_list_cache_snapshot())
|
||||
if route_session_list_cache is not None else None,
|
||||
cache_import_error,
|
||||
),
|
||||
}
|
||||
for name, (collect, import_error) in sources.items():
|
||||
try:
|
||||
if import_error is not None:
|
||||
raise import_error
|
||||
source = collect()
|
||||
if not isinstance(source, dict):
|
||||
raise TypeError("runtime_snapshot_invalid")
|
||||
target = runtime[name]
|
||||
for key in target:
|
||||
if key == "available":
|
||||
target[key] = bool(source.get(key, False))
|
||||
elif key == "age_seconds":
|
||||
age = source.get(key)
|
||||
target[key] = None if age is None else max(0.0, float(age))
|
||||
else:
|
||||
target[key] = _safe_runtime_int(source.get(key, 0))
|
||||
except Exception as exc:
|
||||
errors.append(_safe_error(f"webui_runtime.{name}", exc))
|
||||
return runtime
|
||||
|
||||
|
||||
def build_system_health_payload() -> dict[str, Any]:
|
||||
metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
|
||||
errors: list[dict[str, str]] = []
|
||||
@@ -182,6 +289,15 @@ def build_system_health_payload() -> dict[str, Any]:
|
||||
except Exception as exc:
|
||||
errors.append(_safe_error(name, exc))
|
||||
|
||||
try:
|
||||
runtime = _webui_runtime_payload(errors)
|
||||
except Exception as exc:
|
||||
# Terminal fail-open: an unexpected failure in the runtime compositor
|
||||
# itself must not fail the whole authenticated response, which returned
|
||||
# host metrics before this subtree existed.
|
||||
runtime = _zero_webui_runtime_payload()
|
||||
errors.append(_safe_error("webui_runtime", exc))
|
||||
|
||||
available = any(metrics[name] is not None for name in metrics)
|
||||
status = "ok" if available and not errors else "partial" if available else "unavailable"
|
||||
return {
|
||||
@@ -191,5 +307,6 @@ def build_system_health_payload() -> dict[str, Any]:
|
||||
"cpu": metrics["cpu"],
|
||||
"memory": metrics["memory"],
|
||||
"disk": metrics["disk"],
|
||||
"webui_runtime": runtime,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
+168
-1
@@ -93,6 +93,14 @@ class TerminalSession:
|
||||
# at its prompt has neither, so it sorts oldest and is evicted before an
|
||||
# actively used one.
|
||||
last_activity: float = field(default_factory=time.time)
|
||||
# Wall-clock of when the terminal last had zero attached viewers, or None
|
||||
# while at least one is attached. The reaper closes a terminal that has been
|
||||
# unwatched for longer than the idle grace: a client that drops its output
|
||||
# stream without POSTing /api/terminal/close (tab close, crash, network drop)
|
||||
# otherwise leaves the shell running forever (no PDEATHSIG). A terminal is
|
||||
# born unwatched, so a spawn nobody ever attaches to is reaped too. The grace
|
||||
# spans transient reconnects (a tab refresh re-attaches and clears it).
|
||||
unwatched_since: float | None = field(default_factory=time.time)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return not self.closed.is_set() and self.proc.poll() is None
|
||||
@@ -111,6 +119,7 @@ class TerminalSession:
|
||||
if after_seq is None or item[0] > after_seq:
|
||||
q.put_nowait(item)
|
||||
self._subscribers.append(q)
|
||||
self.unwatched_since = None # a viewer is attached
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
@@ -119,6 +128,8 @@ class TerminalSession:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
if not self._subscribers:
|
||||
self.unwatched_since = time.time()
|
||||
|
||||
def put_output(self, event: str, payload: dict) -> None:
|
||||
self.last_activity = time.time()
|
||||
@@ -163,6 +174,19 @@ _spawn_supervisor_thread: threading.Thread | None = None
|
||||
_terminal_descendant_reaper_lock = threading.Lock()
|
||||
_TERMINAL_DESCENDANT_REAPER_LIMIT = 64
|
||||
|
||||
# Idle-terminal reaper: proactively close terminals whose viewers have all gone
|
||||
# away, instead of leaving an abandoned shell running until the cap evicts it.
|
||||
# A terminal unwatched (zero attached output streams) for longer than the grace
|
||||
# is closed; the grace spans a tab refresh / brief network drop so a real
|
||||
# reconnect keeps the session. Dead-process terminals are swept too as a
|
||||
# belt-and-suspenders for the reader-loop retire.
|
||||
_TERMINAL_IDLE_GRACE_SECONDS = 900 # 15 min unwatched -> reap
|
||||
_TERMINAL_REAPER_INTERVAL_SECONDS = 60
|
||||
_terminal_reaper_started = False
|
||||
_terminal_reaper_lock = threading.Lock()
|
||||
_terminal_reaper_thread: threading.Thread | None = None
|
||||
_terminal_reaper_stop = threading.Event()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SpawnRequest:
|
||||
@@ -414,6 +438,101 @@ def _enforce_terminal_cap(*, exclude_sid: str | None = None) -> None:
|
||||
close_terminal(victim_sid, expected=victim_term)
|
||||
|
||||
|
||||
def _terminals_to_reap(now: float) -> list[tuple[str, TerminalSession]]:
|
||||
"""Return (sid, term) pairs the reaper should close: a dead process, or a
|
||||
terminal unwatched for longer than the idle grace. Pure/snapshotted under
|
||||
the lock so it can be unit-tested without threads."""
|
||||
victims = []
|
||||
with _LOCK:
|
||||
for sid, term in _TERMINALS.items():
|
||||
if not term.is_alive():
|
||||
victims.append((sid, term))
|
||||
continue
|
||||
unwatched = term.unwatched_since
|
||||
if unwatched is not None and (now - unwatched) >= _TERMINAL_IDLE_GRACE_SECONDS:
|
||||
victims.append((sid, term))
|
||||
return victims
|
||||
|
||||
|
||||
def _claim_reap_victim(sid: str, term: TerminalSession, now: float) -> TerminalSession | None:
|
||||
"""Atomically remove *term* from the registry, or refuse.
|
||||
|
||||
``_terminals_to_reap`` snapshots victims and then releases ``_LOCK``, so by
|
||||
the time we get here a viewer may have reconnected: ``subscribe()`` appends
|
||||
to ``_subscribers`` and clears ``unwatched_since`` under the terminal's
|
||||
``_sub_lock``, which the selection pass never held. Object identity alone —
|
||||
what ``close_terminal(expected=…)`` checks — is still true in that case, so
|
||||
the reaper would kill a terminal that now has a live viewer.
|
||||
|
||||
The claim therefore re-establishes the *whole* selection predicate while
|
||||
holding both locks, and takes the entry out of ``_TERMINALS`` in the same
|
||||
critical section. A concurrent ``attach_terminal()`` acquires the same two
|
||||
locks in the same order, so exactly one of the two wins: either the viewer
|
||||
is attached (and we refuse) or the entry is already gone (and the attach
|
||||
reports "not running").
|
||||
|
||||
Returns the claimed terminal — the caller owns its teardown — or ``None``.
|
||||
"""
|
||||
with _LOCK:
|
||||
if _TERMINALS.get(sid) is not term:
|
||||
return None
|
||||
# A dead process is reaped unconditionally: it cannot come back to life,
|
||||
# and an attached viewer only means someone is watching a corpse.
|
||||
if term.is_alive():
|
||||
with term._sub_lock:
|
||||
if term._subscribers:
|
||||
return None
|
||||
unwatched = term.unwatched_since
|
||||
if unwatched is None:
|
||||
return None
|
||||
if (now - unwatched) < _TERMINAL_IDLE_GRACE_SECONDS:
|
||||
return None
|
||||
del _TERMINALS[sid]
|
||||
return term
|
||||
|
||||
|
||||
def _reap_idle_terminals(now: float) -> int:
|
||||
"""Close every terminal selected by ``_terminals_to_reap`` that is *still*
|
||||
idle when claimed. Returns the count closed."""
|
||||
reaped = 0
|
||||
for sid, term in _terminals_to_reap(now):
|
||||
claimed = _claim_reap_victim(sid, term, now)
|
||||
if claimed is None:
|
||||
continue
|
||||
# Process/fd teardown runs after both locks are released: killpg + wait
|
||||
# can take seconds and must not block attaches or spawns.
|
||||
_teardown_terminal(claimed)
|
||||
reaped += 1
|
||||
return reaped
|
||||
|
||||
|
||||
def _terminal_reaper_loop() -> None:
|
||||
while not _terminal_reaper_stop.wait(_TERMINAL_REAPER_INTERVAL_SECONDS):
|
||||
try:
|
||||
# Wall-clock, consistent with unwatched_since / last_activity.
|
||||
_reap_idle_terminals(time.time())
|
||||
except Exception:
|
||||
# Never let a transient error kill the reaper thread.
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_terminal_reaper() -> None:
|
||||
global _terminal_reaper_started, _terminal_reaper_thread
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
return
|
||||
with _terminal_reaper_lock:
|
||||
if (
|
||||
_terminal_reaper_started
|
||||
and _terminal_reaper_thread is not None
|
||||
and getattr(_terminal_reaper_thread, "is_alive", lambda: False)()
|
||||
):
|
||||
return
|
||||
thread = threading.Thread(target=_terminal_reaper_loop, daemon=True)
|
||||
thread.start()
|
||||
_terminal_reaper_thread = thread
|
||||
_terminal_reaper_started = True
|
||||
|
||||
|
||||
def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = 80, restart: bool = False) -> TerminalSession:
|
||||
"""Start or return the embedded terminal for a WebUI session."""
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
@@ -475,6 +594,7 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
|
||||
}
|
||||
)
|
||||
_ensure_spawn_supervisor()
|
||||
_ensure_terminal_reaper()
|
||||
_spawn_queue.put(request)
|
||||
try:
|
||||
if not request.done.wait(timeout=5.0):
|
||||
@@ -522,6 +642,42 @@ def get_terminal(session_id: str) -> TerminalSession | None:
|
||||
return term
|
||||
|
||||
|
||||
def attach_terminal(
|
||||
session_id: str, after_seq: int | None = None
|
||||
) -> tuple[TerminalSession, queue.Queue] | None:
|
||||
"""Attach a viewer atomically against the idle reaper.
|
||||
|
||||
``get_terminal()`` followed by ``term.subscribe()`` leaves a window: the
|
||||
reaper can claim and tear the terminal down in between, so the viewer ends
|
||||
up subscribed to a corpse and the caller reports a live stream that will
|
||||
never produce output. Doing the lookup and the subscribe inside the same
|
||||
``_LOCK`` section — the same lock, in the same order, that
|
||||
``_claim_reap_victim`` takes — makes the two mutually exclusive:
|
||||
|
||||
* attach wins → ``_subscribers`` is non-empty and ``unwatched_since`` is
|
||||
``None`` before the reaper can revalidate, so the reap is refused;
|
||||
* reap wins → the entry is already out of ``_TERMINALS``, so this returns
|
||||
``None`` and the route answers "terminal not running" instead of hanging.
|
||||
|
||||
Registration is the authority, deliberately: both the reaper and
|
||||
``close_terminal()`` remove the entry *before* tearing the terminal down, so
|
||||
"still in ``_TERMINALS``" is exactly the condition that cannot race. A
|
||||
terminal that is registered but already flagged ``closed`` (its shell exited
|
||||
and the reader loop has not retired it yet) still attaches, so the viewer
|
||||
receives the ``terminal_closed`` event instead of a bare 404.
|
||||
|
||||
Returns ``(term, queue)`` or ``None``.
|
||||
"""
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
return None
|
||||
sid = str(session_id or "")
|
||||
with _LOCK:
|
||||
term = _TERMINALS.get(sid)
|
||||
if term is None:
|
||||
return None
|
||||
return term, term.subscribe(after_seq=after_seq)
|
||||
|
||||
|
||||
def write_terminal(session_id: str, data: str) -> None:
|
||||
if not _TERMINAL_SUPPORTED:
|
||||
raise NotImplementedError("Embedded terminal is not supported on Windows")
|
||||
@@ -565,6 +721,18 @@ def close_terminal(session_id: str, *, expected: TerminalSession | None = None)
|
||||
term = _TERMINALS.pop(sid, None)
|
||||
if not term:
|
||||
return False
|
||||
_teardown_terminal(term)
|
||||
return True
|
||||
|
||||
|
||||
def _teardown_terminal(term: TerminalSession) -> None:
|
||||
"""Kill the shell, close the pty master fd and reap descendants.
|
||||
|
||||
Split out of ``close_terminal`` so a caller that has already claimed the
|
||||
registry entry (the idle reaper) can run the teardown without re-entering
|
||||
the registry lookup — and, importantly, without holding any lock while
|
||||
``killpg``/``wait`` block for up to ~2.5s.
|
||||
"""
|
||||
term.closed.set()
|
||||
try:
|
||||
if term.proc.poll() is None:
|
||||
@@ -592,7 +760,6 @@ def close_terminal(session_id: str, *, expected: TerminalSession | None = None)
|
||||
except OSError:
|
||||
pass
|
||||
_reap_terminal_descendants(term.proc.pid)
|
||||
return True
|
||||
|
||||
|
||||
def close_all_terminals() -> None:
|
||||
|
||||
+134
-124
@@ -28,6 +28,7 @@ from api.agent_health import get_active_profile_gateway_running_pid
|
||||
from api.gateway_restart import restart_active_profile_gateway
|
||||
from api.profiles import get_active_profile_name
|
||||
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,7 @@ _check_in_progress = False
|
||||
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
|
||||
CACHE_TTL = 1800 # 30 minutes
|
||||
_AGENT_GATEWAY_RESTART_RETRY_DELAY_S = 1.0
|
||||
_FORCE_DIRTY_PROBE_TIMEOUT = 5
|
||||
_GIT_DIAGNOSTIC_MAX_CHARS = 300
|
||||
_CREDENTIAL_IN_URL_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@\s'\"]+)@")
|
||||
_GITHUB_TOKEN_RE = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")
|
||||
@@ -74,6 +76,29 @@ _GIT_LOCK_SIGNATURES = (
|
||||
'another git process seems to be running',
|
||||
'unable to create .git/index.lock',
|
||||
)
|
||||
|
||||
|
||||
def _windows_restart_spawn(args, **kwargs):
|
||||
"""Spawn the replacement process for a Windows self-restart."""
|
||||
return subprocess.Popen(args, **kwargs)
|
||||
|
||||
|
||||
def _windows_restart_exit(code):
|
||||
"""Exit the old process after a Windows replacement is running."""
|
||||
os._exit(code)
|
||||
|
||||
|
||||
def _windows_restart_command():
|
||||
"""Return the canonical replacement command for the current packaging mode."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return list(sys.argv)
|
||||
|
||||
executable = sys.executable
|
||||
if executable.lower().endswith("python.exe"):
|
||||
windowless_executable = executable[:-4] + "w.exe"
|
||||
if os.path.isfile(windowless_executable):
|
||||
executable = windowless_executable
|
||||
return [executable, str(REPO_ROOT / "server.py")]
|
||||
# Lock files we previously enumerated for auto-removal in v2. v2.2 no longer
|
||||
# removes anything on the server, so the enumerable list is no longer needed;
|
||||
# ``_inventory_locks`` reports whatever ``.git/**/*.lock`` files currently exist
|
||||
@@ -214,9 +239,14 @@ def _run_git(args, cwd, timeout=10):
|
||||
return 'git executable not found', False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[git_executable] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
encoding='utf-8', errors='replace',
|
||||
[git_executable] + args,
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
|
||||
# output that fails to decode used to leave r.stdout = None and crash
|
||||
@@ -430,21 +460,15 @@ def _dirty_suffix(path: Path, timeout=1) -> str:
|
||||
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
||||
if ok:
|
||||
return ""
|
||||
# diff-index --quiet exits 1 with no stdout/stderr to *signal* a dirty tree
|
||||
# (not an error). _run_git() substitutes a synthetic "git exited with
|
||||
# status N" diagnostic when both streams are empty, which makes the naive
|
||||
# `if not out` guard always false on dirty trees — silently dropping the
|
||||
# suffix and defeating dev-build cache busting (static/foo.js?v=… stays
|
||||
# identical to the last-committed version). Treat the synthetic shape as
|
||||
# the dirty signal; real errors (timeouts, missing git) carry a different
|
||||
# diagnostic and correctly suppress the suffix.
|
||||
if not out or out.startswith('git exited with status '):
|
||||
diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout)
|
||||
if diff_ok and diff:
|
||||
digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8]
|
||||
return f"-dirty-{digest}"
|
||||
return "-dirty"
|
||||
return ""
|
||||
# Only diff-index status 1 means dirty. Keep version display consistent
|
||||
# with the strict action-time probe; all other failures suppress the suffix.
|
||||
if out != 'git exited with status 1':
|
||||
return ""
|
||||
diff, diff_ok = _run_git(['diff', '--binary', 'HEAD', '--'], path, timeout=timeout)
|
||||
if diff_ok and diff:
|
||||
digest = hashlib.sha1(diff.encode('utf-8', errors='replace')).hexdigest()[:8]
|
||||
return f"-dirty-{digest}"
|
||||
return "-dirty"
|
||||
|
||||
|
||||
def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None:
|
||||
@@ -1279,6 +1303,22 @@ def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
return None
|
||||
|
||||
|
||||
def _probe_dirty(
|
||||
path: Path, timeout: int = 1, *, legacy_empty_is_dirty: bool = False,
|
||||
) -> bool | None:
|
||||
"""Return dirty, clean, or unknown for a working-tree probe."""
|
||||
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
||||
if ok:
|
||||
return False
|
||||
if out == 'git exited with status 1' or (legacy_empty_is_dirty and (not out or out.startswith('git exited with status '))):
|
||||
return True
|
||||
logger.warning(
|
||||
'git dirty probe failed; treating working-tree state as unknown: %s',
|
||||
out,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _is_dirty(path: Path, timeout: int = 1) -> bool:
|
||||
"""Return True when the working tree has uncommitted changes vs HEAD.
|
||||
|
||||
@@ -1288,10 +1328,11 @@ def _is_dirty(path: Path, timeout: int = 1) -> bool:
|
||||
reported as clean so a transient probe failure never produces a false-
|
||||
positive "local changes" alert.
|
||||
"""
|
||||
out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout)
|
||||
if ok:
|
||||
return False
|
||||
return not out or out.startswith('git exited with status ')
|
||||
# Older checker consumers model diff-index status 1 as ('', False). Keep
|
||||
# that boolean contract here; force updates use the strict tri-state form.
|
||||
return _probe_dirty(
|
||||
path, timeout=timeout, legacy_empty_is_dirty=True,
|
||||
) is True
|
||||
|
||||
|
||||
def _ignored_agent_update_info() -> dict:
|
||||
@@ -1699,9 +1740,9 @@ def _schedule_restart(delay: float = 2.0) -> None:
|
||||
loaded on the next request, rather than running with a mix of old and
|
||||
new Python modules in sys.modules.
|
||||
|
||||
os.execv() replaces the current process image with a fresh interpreter
|
||||
running the same argv — sessions are preserved on disk, the HTTP port
|
||||
is reclaimed within the delay window, and the client's own
|
||||
The restart replaces the current process image or starts the canonical
|
||||
server entrypoint, depending on platform and packaging mode. Sessions are
|
||||
preserved on disk, the HTTP port is reclaimed within the delay window, and the client's own
|
||||
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
|
||||
|
||||
Coordinates with ``_apply_lock``: when the user updates both webui
|
||||
@@ -1739,84 +1780,47 @@ def _schedule_restart(delay: float = 2.0) -> None:
|
||||
try:
|
||||
# Re-exec into the just-pulled image.
|
||||
#
|
||||
# sys.argv[0]'s meaning depends on how the server was launched:
|
||||
#
|
||||
# * Source checkout (`python server.py` via bootstrap.py /
|
||||
# ctl.sh / start.sh): sys.argv[0] is the SCRIPT path
|
||||
# (e.g. "/root/hermes-webui/server.py"), sys.executable is
|
||||
# the interpreter. CPython treats argv[1] as the script to
|
||||
# run, so we must pass [sys.executable] + sys.argv.
|
||||
#
|
||||
# * Frozen/packaged build (PyInstaller, embedded zipapp,
|
||||
# etc.): sys.argv[0] == sys.executable == <binary>. Passing
|
||||
# [sys.executable] + sys.argv would re-insert the binary as
|
||||
# argv[1] — the kernel launches it, the interpreter treats
|
||||
# the binary itself as the "script" to run, and execv
|
||||
# effectively becomes a recursive no-op that never reaches
|
||||
# bind(), leaving the WebUI stuck "offline" after every
|
||||
# self-update. Pass argv as-is instead.
|
||||
#
|
||||
# Distinguish the two cases with sys.frozen (set by
|
||||
# PyInstaller / zipapp / similar). For source checkouts the
|
||||
# `[sys.executable] + sys.argv` form is the canonical CPython
|
||||
# re-exec idiom (same shape Flask/Django reloaders use) and
|
||||
# is the correct path.
|
||||
#
|
||||
# IMPORTANT: On Windows, os.execv() does NOT replace the
|
||||
# current process — it spawns a new process while the old
|
||||
# one keeps running. This causes "address already in use"
|
||||
# because the old process still holds the port. On Windows
|
||||
# we use subprocess.Popen() + os._exit() instead.
|
||||
# we use a detached spawn + exit instead.
|
||||
if sys.platform == 'win32':
|
||||
import subprocess
|
||||
if getattr(sys, "frozen", False):
|
||||
args = sys.argv
|
||||
else:
|
||||
args = [sys.executable] + sys.argv
|
||||
# Prefer pythonw.exe over python.exe so the restarted
|
||||
# server does not create a visible console window.
|
||||
# sys.executable may point at python.exe (console
|
||||
# subsystem); substitute pythonw.exe if it exists
|
||||
# next to python.exe.
|
||||
_exe = sys.executable
|
||||
if _exe.lower().endswith('python.exe'):
|
||||
_w_exe = _exe[:-4] + 'w.exe' # python.exe -> pythonw.exe
|
||||
if os.path.isfile(_w_exe):
|
||||
if getattr(sys, "frozen", False):
|
||||
args = sys.argv
|
||||
else:
|
||||
args = [_w_exe] + sys.argv
|
||||
args = _windows_restart_command()
|
||||
# Start new process fully detached with NO console
|
||||
# window. DETACHED_PROCESS alone is not sufficient
|
||||
# on modern Windows — without CREATE_NO_WINDOW a
|
||||
# python.exe (console-subsystem) child still flashes
|
||||
# an empty terminal window, which the user then
|
||||
# manually kills (taking the WebUI with it).
|
||||
subprocess.Popen(
|
||||
args,
|
||||
cwd=os.getcwd(),
|
||||
creationflags=(
|
||||
subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_NO_WINDOW
|
||||
),
|
||||
close_fds=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
_windows_restart_spawn(
|
||||
args,
|
||||
cwd=os.getcwd(),
|
||||
creationflags=(
|
||||
subprocess.DETACHED_PROCESS
|
||||
| subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
| subprocess.CREATE_NO_WINDOW
|
||||
),
|
||||
close_fds=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Windows WebUI restart spawn failed")
|
||||
return
|
||||
# Exit immediately — the port is released as soon as
|
||||
# this process dies, allowing the new process to bind.
|
||||
os._exit(0)
|
||||
_windows_restart_exit(0)
|
||||
else:
|
||||
if getattr(sys, "frozen", False):
|
||||
os.execv(sys.executable, sys.argv)
|
||||
else:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
# Last-resort: if execv fails for any reason, just exit so the
|
||||
# process supervisor (start.sh / Docker) restarts us.
|
||||
os._exit(0)
|
||||
# Last-resort: let the process supervisor restart us.
|
||||
_windows_restart_exit(0)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
@@ -1899,17 +1903,41 @@ def _agent_gateway_restart_failure_message(target: str, restart_result: dict) ->
|
||||
)
|
||||
|
||||
|
||||
def _discard_local_changes(path: Path, reset_ref: str) -> bool:
|
||||
"""Discard local changes and reset *path* to *reset_ref*."""
|
||||
# Do not use -x: ignored build/cache artifacts should survive force update.
|
||||
_run_git(['checkout', '.'], path)
|
||||
# Best-effort clean: a `git clean -fd` failure is NOT fatal. The
|
||||
# following `reset --hard` overwrites any tracked-file collisions
|
||||
# regardless, and residual untracked files that git can't delete are
|
||||
# harmless. In particular, on Windows a file named after a reserved
|
||||
# device name (nul, con, prn, aux, com1-9, lpt1-9) — which can appear
|
||||
# in the working tree when a shell command redirects to `> nul` under
|
||||
# Git Bash — cannot be removed via the normal Win32 path that git uses,
|
||||
# so `clean` exits non-zero. Aborting the whole force update over that
|
||||
# left users stuck (issue #4914). Log the stderr for diagnostics and
|
||||
# proceed to the reset, which is what actually applies the update.
|
||||
clean_out, clean_ok = _run_git(['clean', '-fd'], path)
|
||||
if not clean_ok:
|
||||
logger.warning(
|
||||
'force_apply_update: `git clean -fd` failed (non-fatal, '
|
||||
'continuing to reset --hard): %s',
|
||||
clean_out,
|
||||
)
|
||||
_, ok = _run_git(['reset', '--hard', reset_ref], path)
|
||||
return ok
|
||||
|
||||
|
||||
def apply_force_update(target: str, channel=None) -> dict:
|
||||
"""Force-reset the target repo to the latest remote HEAD.
|
||||
"""Discard local changes for the requested update target.
|
||||
|
||||
Unlike apply_update() which requires a clean working tree and refuses
|
||||
merge conflicts, this discards all local modifications (checkout .) and
|
||||
resets to origin/<branch> — equivalent to what the diverged/conflict
|
||||
error messages ask the user to run manually.
|
||||
resets to the selected update ref. A dirty stable WebUI checkout with no
|
||||
promoted ref resets to its symbolic HEAD so the commit itself is unchanged.
|
||||
|
||||
Should only be called when apply_update() has already returned a
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
The endpoint is called after the user has confirmed they want to discard
|
||||
local changes, including the stable no-ref dirty-checkout recovery path.
|
||||
|
||||
CHANNEL SAFETY (rewind guard): ``reset --hard`` is destructive. When the
|
||||
selected channel resolves to a ref that is an ANCESTOR of HEAD (i.e. the
|
||||
@@ -1967,13 +1995,19 @@ def apply_force_update(target: str, channel=None) -> dict:
|
||||
# force to. Do NOT fall back to origin/master (firehose). See
|
||||
# _select_apply_compare_ref channel semantics.
|
||||
if compare_ref is None:
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} is already up to date on the {channel} channel.',
|
||||
'target': target,
|
||||
'up_to_date': True,
|
||||
'channel': channel,
|
||||
}
|
||||
dirty_state = None
|
||||
if target == 'webui' and channel == 'stable':
|
||||
dirty_state = _probe_dirty(path, timeout=_FORCE_DIRTY_PROBE_TIMEOUT)
|
||||
if dirty_state is True:
|
||||
compare_ref = 'HEAD'
|
||||
else:
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} is already up to date on the {channel} channel.',
|
||||
'target': target,
|
||||
'up_to_date': True,
|
||||
'channel': channel,
|
||||
}
|
||||
|
||||
# Rewind guard (Codex CORE #3): refuse to reset --hard onto a ref that
|
||||
# is an ANCESTOR of HEAD — that would downgrade the checkout. This is the
|
||||
@@ -1995,28 +2029,7 @@ def apply_force_update(target: str, channel=None) -> dict:
|
||||
'channel': channel,
|
||||
'refused_rewind': True,
|
||||
}
|
||||
# Discard local modifications and untracked colliders before resetting.
|
||||
# Do not use -x: ignored build/cache artifacts should survive force update.
|
||||
_run_git(['checkout', '.'], path)
|
||||
# Best-effort clean: a `git clean -fd` failure is NOT fatal. The
|
||||
# following `reset --hard` overwrites any tracked-file collisions
|
||||
# regardless, and residual untracked files that git can't delete are
|
||||
# harmless. In particular, on Windows a file named after a reserved
|
||||
# device name (nul, con, prn, aux, com1-9, lpt1-9) — which can appear
|
||||
# in the working tree when a shell command redirects to `> nul` under
|
||||
# Git Bash — cannot be removed via the normal Win32 path that git uses,
|
||||
# so `clean` exits non-zero. Aborting the whole force update over that
|
||||
# left users stuck (issue #4914). Log the stderr for diagnostics and
|
||||
# proceed to the reset, which is what actually applies the update.
|
||||
clean_out, clean_ok = _run_git(['clean', '-fd'], path)
|
||||
if not clean_ok:
|
||||
logger.warning(
|
||||
'force_apply_update: `git clean -fd` failed (non-fatal, '
|
||||
'continuing to reset --hard): %s',
|
||||
clean_out,
|
||||
)
|
||||
_, ok = _run_git(['reset', '--hard', compare_ref], path)
|
||||
if not ok:
|
||||
if not _discard_local_changes(path, compare_ref):
|
||||
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
|
||||
|
||||
with _cache_lock:
|
||||
@@ -2379,11 +2392,8 @@ def _apply_update_inner(target, channel=DEFAULT_UPDATE_CHANNEL):
|
||||
}
|
||||
|
||||
# Schedule a self-restart so the updated code is loaded fresh. A plain
|
||||
# git pull leaves stale Python modules in sys.modules — agent imports that
|
||||
# reference new symbols (functions, classes) added in the update will fail
|
||||
# on the next request with AttributeError / ImportError. os.execv() re-
|
||||
# execs the same interpreter with the same argv, picking up the new code
|
||||
# cleanly without requiring the user to restart manually.
|
||||
# git pull leaves stale Python modules in sys.modules. Replacing the process
|
||||
# loads the updated code cleanly without requiring a manual restart.
|
||||
#
|
||||
# The 2 s delay gives the HTTP response time to flush to the client before
|
||||
# the process replaces itself. The client already does
|
||||
|
||||
+23
-6
@@ -122,8 +122,8 @@ def _attachment_root() -> Path:
|
||||
return (STATE_DIR / 'attachments').resolve()
|
||||
|
||||
|
||||
def _upload_destination(session_id: str, safe_name: str) -> Path:
|
||||
dest_dir = _session_attachment_dir(session_id)
|
||||
def _upload_destination(session_id: str, safe_name: str, dest_dir: Path | None = None) -> Path:
|
||||
dest_dir = dest_dir if dest_dir is not None else _session_attachment_dir(session_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = (dest_dir / safe_name).resolve()
|
||||
if not dest.is_relative_to(dest_dir):
|
||||
@@ -224,8 +224,20 @@ def handle_upload(handler):
|
||||
if _reject_invisible_session(handler, s):
|
||||
return True
|
||||
safe_name = _sanitize_upload_name(filename)
|
||||
dest = _upload_destination(session_id, safe_name)
|
||||
dest.write_bytes(file_bytes)
|
||||
dest_dir = _session_attachment_dir(session_id)
|
||||
dest = _upload_destination(session_id, safe_name, dest_dir)
|
||||
# #3398-style TOCTOU hardening, mirrored from the workspace upload
|
||||
# path: O_CREAT|O_EXCL|O_NOFOLLOW anchored open so a raced duplicate
|
||||
# cannot be silently overwritten and a raced symlink subpath cannot
|
||||
# redirect the write outside the attachment dir.
|
||||
try:
|
||||
_wfd = open_anchored_create_fd(dest_dir, dest)
|
||||
except FileExistsError:
|
||||
return j(handler, {'error': f'Upload destination already exists: {safe_name}'}, status=409)
|
||||
except (ValueError, OSError):
|
||||
return j(handler, {'error': 'Upload destination rejected'}, status=403)
|
||||
with os.fdopen(_wfd, 'wb', closefd=True) as _wfh:
|
||||
_wfh.write(file_bytes)
|
||||
mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
|
||||
return j(handler, {
|
||||
'filename': dest.name,
|
||||
@@ -623,8 +635,13 @@ def handle_workspace_upload(handler):
|
||||
if _reject_invisible_session(handler, session):
|
||||
return True
|
||||
|
||||
# Resolve workspace root from session
|
||||
workspace = resolve_trusted_workspace(session.workspace)
|
||||
# Resolve workspace root using the session profile, not the ambient request profile.
|
||||
try:
|
||||
workspace = resolve_trusted_workspace(
|
||||
session.workspace, profile=getattr(session, "profile", None)
|
||||
)
|
||||
except TypeError:
|
||||
workspace = resolve_trusted_workspace(session.workspace)
|
||||
|
||||
# Resolve target subdirectory within workspace
|
||||
target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
|
||||
|
||||
+402
-69
@@ -12,13 +12,16 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,12 +36,19 @@ from api.config import (
|
||||
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||
)
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
# Logical profile-name grammar — mirrors api.profiles._PROFILE_ID_RE. Kept as
|
||||
# a local copy so workspace-layer validation does not import profiles at module
|
||||
# load time (profiles may not be importable in every embedding context).
|
||||
_PROFILE_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
||||
|
||||
|
||||
def _profile_state_dir(profile: str | Path | None = None) -> Path:
|
||||
"""Return the webui_state directory for the active or given profile.
|
||||
|
||||
For the default profile, returns the global STATE_DIR (respects
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
@@ -46,6 +56,28 @@ def _profile_state_dir() -> Path:
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
if profile is not None:
|
||||
# Literal-"default" STATE routing (#7168 re-gate round 7): the
|
||||
# default profile's workspace state always lives in the global
|
||||
# state files, even when isolated mode pins the default home at
|
||||
# <base>/profiles/default. The round-6 resolver change made
|
||||
# _resolve_profile_home_param("default") return that pinned home,
|
||||
# so the canonical-home check below sent explicit
|
||||
# profile="default" state reads/writes to {pinned}/webui_state/
|
||||
# while ambient calls kept using the global dir — splitting saved
|
||||
# workspaces between two authorities. Config and workspace PATH
|
||||
# resolution still use the pinned home via
|
||||
# _resolve_profile_home_param; only this state-file tier stays
|
||||
# global for the logical string "default".
|
||||
if isinstance(profile, str) and profile.strip() == 'default':
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
if not _is_default_profile_home(profile_home):
|
||||
d = profile_home / 'webui_state'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
name = get_active_profile_name()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
@@ -56,14 +88,41 @@ def _profile_state_dir() -> Path:
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
def _workspaces_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the workspaces.json path for the active or given profile."""
|
||||
return _profile_state_dir(profile=profile) / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
def _last_workspace_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the last_workspace.txt path for the active or given profile."""
|
||||
return _profile_state_dir(profile=profile) / 'last_workspace.txt'
|
||||
|
||||
|
||||
def _workspaces_file_for_profile(profile: str | Path | None = None) -> Path | None:
|
||||
"""Profile-scoped workspaces.json path, or None for an INVALID profile.
|
||||
|
||||
``None`` is the fail-closed contract (#7168 re-gate round 4): a malformed
|
||||
profile name must not be clamped onto the default/global state files, so
|
||||
callers treat it as "no readable/writable profile-local state".
|
||||
"""
|
||||
try:
|
||||
return _workspaces_file(profile=profile) if profile is not None else _workspaces_file()
|
||||
except TypeError:
|
||||
return _workspaces_file()
|
||||
except ValueError:
|
||||
logger.debug("Ignoring invalid profile name %r for workspaces file", profile)
|
||||
return None
|
||||
|
||||
|
||||
def _last_workspace_file_for_profile(profile: str | Path | None = None) -> Path | None:
|
||||
"""Profile-scoped last_workspace.txt path, or None for an INVALID profile."""
|
||||
try:
|
||||
return _last_workspace_file(profile=profile) if profile is not None else _last_workspace_file()
|
||||
except TypeError:
|
||||
return _last_workspace_file()
|
||||
except ValueError:
|
||||
logger.debug("Ignoring invalid profile name %r for last-workspace file", profile)
|
||||
return None
|
||||
|
||||
|
||||
def _expanduser_path(path: str | Path) -> Path:
|
||||
@@ -98,8 +157,11 @@ def _expanduser_path(path: str | Path) -> Path:
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _resolve_path(path: str | Path) -> Path:
|
||||
"""Resolve *path* after env-aware home expansion, without raising."""
|
||||
def _resolve_path(path: str | Path, profile: str | Path | None = None) -> Path:
|
||||
"""Resolve *path* after env-aware home expansion, preserving remote POSIX paths without raising."""
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if remote_candidate is not None:
|
||||
return remote_candidate
|
||||
return _safe_resolve(_expanduser_path(path))
|
||||
|
||||
|
||||
@@ -145,12 +207,75 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool:
|
||||
return backend not in ('', 'local')
|
||||
|
||||
|
||||
def _remote_terminal_cwd() -> str | None:
|
||||
"""Return target-side terminal cwd for remote profiles, without local stat()."""
|
||||
def _resolve_profile_home_param(profile: str | Path | None) -> Path:
|
||||
"""Resolve a profile parameter (name string, directory path string, or Path) to a profile home Path.
|
||||
|
||||
Logical profile names are validated strictly (#7168 re-gate round 4): a
|
||||
name that fails the profile-id grammar raises ValueError instead of being
|
||||
silently clamped onto the default home — the old clamp let a malformed
|
||||
name such as ``"bad name"`` read/write the DEFAULT profile's state.
|
||||
Round 5 tightens the grammar gate: a STRING profile value is strictly a
|
||||
logical profile id and is NEVER treated as a path-shaped home — the old
|
||||
``"/" in raw`` branch resolved any slash-bearing string directly, so a
|
||||
malformed value like ``"../evil"`` bypassed validation entirely and could
|
||||
read/overwrite an arbitrary ``webui_state/last_workspace.txt``
|
||||
(#7168 re-gate round 5, path traversal on the profile-isolation boundary).
|
||||
Round 6 closes the last isolation hole in this resolver: the logical
|
||||
string ``"default"`` used to short-circuit to ``_DEFAULT_HERMES_HOME``
|
||||
before reaching ``get_hermes_home_for_profile()``, bypassing the
|
||||
isolated-mode clamp in ``api.profiles._resolve_profile_home_for_name``
|
||||
(#7168 re-gate round 6). In an isolated deployment pinned at
|
||||
``<base>/profiles/default``, a session created with ``profile="default"``
|
||||
therefore resolved workspace/config from the BASE root home instead of
|
||||
the pinned one. The name now flows through the same delegated path as
|
||||
every other logical id; literal-default routing to the global state
|
||||
files is retained in ``_profile_state_dir``/``get_last_workspace`` via
|
||||
canonical ``_is_default_profile_home`` identity.
|
||||
An explicit home directory is expressed as a ``Path`` object (callers such
|
||||
as streaming's legacy ``_profile_home`` fallback wrap their home strings
|
||||
in ``Path``); Path values are honored and canonicalized so identity
|
||||
comparisons never depend on lexical spelling (e.g. a symlink alias of the
|
||||
default home must compare equal to it).
|
||||
"""
|
||||
if profile is None or str(profile).strip() == "":
|
||||
from api.profiles import get_active_hermes_home
|
||||
return get_active_hermes_home()
|
||||
|
||||
raw = str(profile).strip()
|
||||
|
||||
if isinstance(profile, Path):
|
||||
return _safe_resolve(profile.expanduser())
|
||||
|
||||
# Strings are LOGICAL PROFILE IDS ONLY — no path-shaped strings, ever.
|
||||
if not _PROFILE_NAME_RE.fullmatch(raw):
|
||||
raise ValueError(f"invalid profile name: {raw!r}")
|
||||
|
||||
from api.profiles import get_hermes_home_for_profile
|
||||
return _safe_resolve(get_hermes_home_for_profile(raw))
|
||||
|
||||
|
||||
def _is_default_profile_home(profile_home: Path) -> bool:
|
||||
"""Canonical identity check against the root/default Hermes home.
|
||||
|
||||
Compares resolved paths so a symlink alias of _DEFAULT_HERMES_HOME is
|
||||
recognized as the default profile rather than treated as a foreign,
|
||||
lexically-different directory (#7168 re-gate round 4).
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
from api.profiles import _DEFAULT_HERMES_HOME
|
||||
return _safe_resolve(profile_home) == _safe_resolve(_DEFAULT_HERMES_HOME)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _remote_terminal_cwd(profile: str | Path | None = None) -> str | None:
|
||||
"""Return target-side terminal cwd for a remote profile, without local stat()."""
|
||||
try:
|
||||
from api.config import get_config_for_profile_home
|
||||
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
terminal_cfg = get_config_for_profile_home(profile_home).get('terminal', {})
|
||||
|
||||
terminal_cfg = get_config().get('terminal', {})
|
||||
if not _is_remote_terminal_backend(terminal_cfg):
|
||||
return None
|
||||
cwd = str(terminal_cfg.get('cwd') or '').strip()
|
||||
@@ -162,9 +287,19 @@ def _remote_terminal_cwd() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
"""Return a non-stat'ed target-side Path when it is under terminal.cwd."""
|
||||
cwd = _remote_terminal_cwd()
|
||||
def _remote_terminal_workspace_candidate(path: str | Path, profile: str | Path | None = None) -> Path | None:
|
||||
"""Return a non-stat'ed target-side Path when it is under terminal.cwd for the given profile.
|
||||
|
||||
Remote workspace paths live on the target host (e.g., remote SSH/Docker
|
||||
backend). For valid target-side POSIX paths under ``terminal.cwd``, the
|
||||
normalized target path is preserved as a ``Path`` object without invoking
|
||||
local host-filesystem resolution (avoiding host-specific firmlink rewriting
|
||||
such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``).
|
||||
"""
|
||||
try:
|
||||
cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
cwd = _remote_terminal_cwd()
|
||||
if not cwd:
|
||||
return None
|
||||
raw = _strip_surrounding_quotes(str(path)).strip()
|
||||
@@ -180,10 +315,10 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
if _is_blocked_workspace_path(Path(normalized_raw), normalized_raw) or _is_blocked_workspace_path(Path(normalized_cwd), normalized_cwd):
|
||||
return None
|
||||
if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base):
|
||||
return _resolve_path(normalized_raw)
|
||||
return Path(normalized_raw)
|
||||
return None
|
||||
candidate = _resolve_path(raw)
|
||||
base = _resolve_path(cwd)
|
||||
candidate = _safe_resolve(_expanduser_path(raw))
|
||||
base = _safe_resolve(_expanduser_path(cwd))
|
||||
if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd):
|
||||
return None
|
||||
if candidate == base or _is_within(candidate, base):
|
||||
@@ -191,7 +326,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile_default_workspace() -> str:
|
||||
def _profile_default_workspace(profile: str | Path | None = None) -> str:
|
||||
"""Read the profile's default workspace from its config.yaml.
|
||||
|
||||
Checks keys in priority order:
|
||||
@@ -207,8 +342,9 @@ def _profile_default_workspace() -> str:
|
||||
Falls back to the live DEFAULT_WORKSPACE from api.config.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
from api.config import get_config_for_profile_home
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
cfg = get_config_for_profile_home(profile_home)
|
||||
terminal_cfg = cfg.get('terminal', {})
|
||||
remote_terminal = _is_remote_terminal_backend(terminal_cfg)
|
||||
# Explicit webui workspace keys first
|
||||
@@ -217,7 +353,7 @@ def _profile_default_workspace() -> str:
|
||||
if ws:
|
||||
if remote_terminal:
|
||||
return str(ws).strip()
|
||||
p = _resolve_path(str(ws))
|
||||
p = _resolve_path(str(ws), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
# Fall through to terminal.cwd — the agent's configured working directory
|
||||
@@ -226,7 +362,7 @@ def _profile_default_workspace() -> str:
|
||||
if cwd and str(cwd) not in ('.', ''):
|
||||
if remote_terminal:
|
||||
return str(cwd).strip()
|
||||
p = _resolve_path(str(cwd))
|
||||
p = _resolve_path(str(cwd), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
@@ -234,15 +370,17 @@ def _profile_default_workspace() -> str:
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
|
||||
|
||||
return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE))
|
||||
return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE, profile=profile))
|
||||
except Exception:
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE))
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE, profile=profile))
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _clean_workspace_list(workspaces: list) -> list:
|
||||
def _clean_workspace_list(workspaces: list, profile: str | Path | None = None) -> list:
|
||||
"""Sanitize a workspace list:
|
||||
- Preserve target-side remote terminal workspace paths (SSH/Docker) without
|
||||
resolving them against the local WebUI host filesystem.
|
||||
- Preserve saved paths even when they are currently missing or inaccessible;
|
||||
picker state must not be destroyed by a transient stat/permission failure.
|
||||
- Remove entries whose paths live inside another profile's directory
|
||||
@@ -258,7 +396,11 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
name = w.get('name', '')
|
||||
if not path:
|
||||
continue
|
||||
p = _safe_resolve(_expanduser_path(path))
|
||||
remote_cand = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if remote_cand is not None:
|
||||
p = remote_cand
|
||||
else:
|
||||
p = _safe_resolve(_expanduser_path(path))
|
||||
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
|
||||
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
|
||||
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
|
||||
@@ -267,7 +409,14 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
own_profile_dir = get_active_hermes_home().resolve()
|
||||
if profile is not None:
|
||||
# Explicit profile wins: the list belongs to that profile,
|
||||
# so "own" is defined by the profile parameter, never by the
|
||||
# ambient home (loading profile A's list under ambient B must
|
||||
# not silently drop A's own workspaces).
|
||||
own_profile_dir = _resolve_profile_home_param(profile).resolve()
|
||||
else:
|
||||
own_profile_dir = get_active_hermes_home().resolve()
|
||||
p.relative_to(own_profile_dir)
|
||||
# p is under our own profile dir — keep it
|
||||
except (ValueError, Exception):
|
||||
@@ -334,12 +483,12 @@ def _migrate_global_workspaces() -> list:
|
||||
return []
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
def load_workspaces(profile: str | Path | None = None) -> list:
|
||||
ws_file = _workspaces_file_for_profile(profile)
|
||||
if ws_file is not None and ws_file.exists():
|
||||
try:
|
||||
raw = json.loads(ws_file.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
cleaned = _clean_workspace_list(raw, profile=profile)
|
||||
if len(cleaned) != len(raw):
|
||||
# Persist the cleaned version so stale entries don't keep reappearing
|
||||
try:
|
||||
@@ -348,7 +497,7 @@ def load_workspaces() -> list:
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist cleaned workspace list")
|
||||
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
return cleaned or [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
|
||||
except Exception:
|
||||
logger.debug("Failed to load workspaces from %s", ws_file)
|
||||
# No profile-local file yet.
|
||||
@@ -356,7 +505,11 @@ def load_workspaces() -> list:
|
||||
# For NAMED profiles: always start clean with just their own workspace.
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
if profile is not None:
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
is_default = _is_default_profile_home(profile_home)
|
||||
else:
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
except ImportError:
|
||||
is_default = True
|
||||
if is_default:
|
||||
@@ -364,16 +517,20 @@ def load_workspaces() -> list:
|
||||
if migrated:
|
||||
return migrated
|
||||
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
return [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list) -> None:
|
||||
ws_file = _workspaces_file()
|
||||
def save_workspaces(workspaces: list, profile: str | Path | None = None) -> None:
|
||||
ws_file = _workspaces_file_for_profile(profile)
|
||||
if ws_file is None:
|
||||
# Fail-closed: an invalid profile name must not write any state file
|
||||
# (it would land in the default profile's directory via the old clamp).
|
||||
raise ValueError(f"cannot save workspaces for invalid profile {profile!r}")
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def get_profile_default_workspace() -> str:
|
||||
def get_profile_default_workspace(profile: str | Path | None = None) -> str:
|
||||
"""Resolve the ACTIVE PROFILE's default workspace, never the global file.
|
||||
|
||||
Like get_last_workspace() but WITHOUT the global ``_GLOBAL_LW_FILE``
|
||||
@@ -388,32 +545,38 @@ def get_profile_default_workspace() -> str:
|
||||
Priority: profile-scoped ``last_workspace.txt`` -> profile ``config.yaml``
|
||||
``workspace``/``default_workspace`` -> ``terminal.cwd`` -> process default.
|
||||
"""
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
try:
|
||||
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
|
||||
def _valid(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
if remote_cwd:
|
||||
if _remote_terminal_workspace_candidate(raw) is not None:
|
||||
if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
|
||||
return raw
|
||||
return None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = _valid(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read profile last workspace from %s", lw_file)
|
||||
return _profile_default_workspace()
|
||||
return _profile_default_workspace(profile=profile)
|
||||
|
||||
|
||||
def get_last_workspace() -> str:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
def get_last_workspace(profile: str | Path | None = None) -> str:
|
||||
try:
|
||||
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
|
||||
def valid_last_workspace(raw: str) -> str | None:
|
||||
if not raw:
|
||||
@@ -422,35 +585,58 @@ def get_last_workspace() -> str:
|
||||
# For remote/SSH profiles, last_workspace is target-side state. Do
|
||||
# not accept stale server-local paths merely because they exist on
|
||||
# the WebUI host; require the value to stay under terminal.cwd.
|
||||
if _remote_terminal_workspace_candidate(raw) is not None:
|
||||
if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
|
||||
return raw
|
||||
return None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read last workspace from %s", lw_file)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
# Fallback: try global file — but ONLY for the root/default profile. A named
|
||||
# profile must never inherit another profile's last-workspace binding through
|
||||
# the legacy global state (#7168 re-gate round 3). Identity is canonical:
|
||||
# a symlink alias of the default home still counts as default, while a
|
||||
# malformed profile name fails validation and is denied the fallback
|
||||
# rather than being clamped onto the global file (#7168 re-gate round 4).
|
||||
_global_fallback_allowed = True # ambient / no explicit profile: historical behavior
|
||||
if profile is not None:
|
||||
if str(profile).strip() == 'default':
|
||||
_global_fallback_allowed = True
|
||||
else:
|
||||
try:
|
||||
_global_fallback_allowed = _is_default_profile_home(
|
||||
_resolve_profile_home_param(profile)
|
||||
)
|
||||
except Exception:
|
||||
# Conservative default: an unresolvable explicit profile is NOT the
|
||||
# root profile — deny the global fallback rather than leak.
|
||||
_global_fallback_allowed = False
|
||||
if _global_fallback_allowed and _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read global last workspace")
|
||||
return _profile_default_workspace()
|
||||
return _profile_default_workspace(profile=profile)
|
||||
|
||||
|
||||
def set_last_workspace(path: str) -> None:
|
||||
def set_last_workspace(path: str, profile: str | Path | None = None) -> None:
|
||||
try:
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is None:
|
||||
# Fail-closed: an invalid profile name must not write the default
|
||||
# profile's last_workspace.txt (#7168 re-gate round 4).
|
||||
logger.debug("Refusing to set last workspace for invalid profile %r", profile)
|
||||
return
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
lw_file.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
@@ -667,7 +853,15 @@ def _is_within(path: Path, root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_workspace_roots() -> list[Path]:
|
||||
def _trusted_workspace_roots(profile: str | Path | None = None) -> list[Path]:
|
||||
"""Return the host directories workspace suggestions may traverse.
|
||||
|
||||
Saved-workspace roots follow the same trust rule as
|
||||
:func:`resolve_trusted_workspace`: with an explicit *profile*, only
|
||||
workspaces saved under THAT profile widen the boundary (plus the ambient
|
||||
home / boot-default carve-outs); ``None`` keeps the historical ambient /
|
||||
global saved-list behaviour.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
|
||||
def add(candidate: str | Path | None) -> None:
|
||||
@@ -686,24 +880,26 @@ def _trusted_workspace_roots() -> list[Path]:
|
||||
|
||||
add(_home_path())
|
||||
add(_BOOT_DEFAULT_WORKSPACE)
|
||||
for w in load_workspaces():
|
||||
for w in load_workspaces(profile=profile):
|
||||
add(w.get("path"))
|
||||
roots.sort(key=lambda p: len(str(p)))
|
||||
return roots
|
||||
|
||||
|
||||
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
def list_workspace_suggestions(
|
||||
prefix: str = "", limit: int = 12, profile: str | Path | None = None
|
||||
) -> list[str]:
|
||||
"""Return workspace path suggestions under trusted roots only.
|
||||
|
||||
Suggestions are limited to directories under one of:
|
||||
- Path.home()
|
||||
- the boot default workspace
|
||||
- already-saved workspace roots
|
||||
- already-saved workspace roots (scoped to *profile* when given)
|
||||
|
||||
Arbitrary system prefixes return an empty list rather than an error so the
|
||||
UI can safely autocomplete while the user types.
|
||||
"""
|
||||
roots = _trusted_workspace_roots()
|
||||
roots = _trusted_workspace_roots(profile=profile)
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
@@ -799,7 +995,7 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
def resolve_trusted_workspace(path: str | Path | None = None, profile: str | Path | None = None) -> Path:
|
||||
"""Resolve and validate a workspace path.
|
||||
|
||||
A path is trusted if it satisfies at least one of:
|
||||
@@ -821,12 +1017,12 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
trusted (it was validated at server startup).
|
||||
"""
|
||||
if path in (None, ""):
|
||||
return _resolve_path(_BOOT_DEFAULT_WORKSPACE)
|
||||
return _resolve_path(_BOOT_DEFAULT_WORKSPACE, profile) if profile is not None else _resolve_path(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
candidate = _resolve_path(path)
|
||||
candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
|
||||
|
||||
access_error = _workspace_access_error(candidate)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# For remote terminal profiles, workspace paths belong to the target
|
||||
# machine. Allow paths under terminal.cwd so session switching can
|
||||
@@ -853,6 +1049,11 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
|
||||
# (B) Trusted if already in the saved workspace list — covers non-home installs
|
||||
try:
|
||||
saved = load_workspaces(profile=profile)
|
||||
saved_paths = {_resolve_path(w["path"], profile) for w in saved if w.get("path")}
|
||||
if candidate in saved_paths:
|
||||
return candidate
|
||||
except TypeError:
|
||||
saved = load_workspaces()
|
||||
saved_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")}
|
||||
if candidate in saved_paths:
|
||||
@@ -879,6 +1080,84 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def resolve_implicit_workspace_with_recovery(
|
||||
candidate: str | Path | None,
|
||||
fallback: str | Path | None | Callable[[], str | Path | None],
|
||||
profile: str | Path | None = None,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Resolve an implicit workspace, recovering only a genuinely missing path.
|
||||
|
||||
The fallback still passes through :func:`resolve_trusted_workspace`. Existing
|
||||
but untrusted, inaccessible, or non-directory candidates are not recovery
|
||||
cases: their original validation error is preserved so fallback cannot widen
|
||||
the workspace trust boundary. When *profile* is given, both the trust
|
||||
resolution and the recovery fallback are scoped to that profile.
|
||||
"""
|
||||
try:
|
||||
if profile is not None:
|
||||
try:
|
||||
return resolve_trusted_workspace(candidate, profile=profile), False
|
||||
except TypeError:
|
||||
pass
|
||||
return resolve_trusted_workspace(candidate), False
|
||||
except ValueError as original_error:
|
||||
if candidate in (None, ""):
|
||||
raise original_error from None
|
||||
# Remote terminal workspaces live on the target host. Classify the
|
||||
# backend independently of terminal.cwd: remote backends may omit cwd,
|
||||
# set it to an empty string, or use ".". A failed host-local stat can
|
||||
# never prove target-side deletion. Config-read uncertainty also fails
|
||||
# closed by preserving the original validation error.
|
||||
try:
|
||||
from api.config import get_config, get_config_for_profile_home
|
||||
|
||||
if profile is not None:
|
||||
# Classify the backend from THIS profile's own config, never the
|
||||
# ambient one: a remote profile without terminal.cwd must still be
|
||||
# recognized as remote when loaded under a different ambient home.
|
||||
terminal_cfg = get_config_for_profile_home(
|
||||
_resolve_profile_home_param(profile)
|
||||
).get("terminal", {})
|
||||
else:
|
||||
terminal_cfg = get_config().get("terminal", {})
|
||||
except Exception:
|
||||
logger.debug("Failed to classify terminal backend for workspace recovery", exc_info=True)
|
||||
raise original_error from None
|
||||
if _is_remote_terminal_backend(terminal_cfg):
|
||||
raise original_error from None
|
||||
try:
|
||||
local_candidate = (
|
||||
_resolve_path(candidate, profile=profile)
|
||||
if profile is not None
|
||||
else _resolve_path(candidate)
|
||||
)
|
||||
local_candidate.stat()
|
||||
except FileNotFoundError:
|
||||
def _profile_bound_fallback():
|
||||
if profile is not None and callable(fallback):
|
||||
# Profile-bound getter FIRST (#7168 re-gate round 3): the
|
||||
# production call sites pass profile-aware getters such as
|
||||
# get_last_workspace, so binding the explicit profile must
|
||||
# take precedence over any zero-argument compatibility call,
|
||||
# which would read ambient/global state.
|
||||
try:
|
||||
return fallback(profile)
|
||||
except TypeError:
|
||||
pass
|
||||
return fallback() if callable(fallback) else fallback
|
||||
|
||||
fallback_value = _profile_bound_fallback()
|
||||
if profile is not None:
|
||||
try:
|
||||
return resolve_trusted_workspace(fallback_value, profile=profile), True
|
||||
except TypeError:
|
||||
pass
|
||||
return resolve_trusted_workspace(fallback_value), True
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise original_error from None
|
||||
raise original_error from None
|
||||
|
||||
|
||||
|
||||
|
||||
def _strip_surrounding_quotes(path: str) -> str:
|
||||
@@ -900,7 +1179,7 @@ def _strip_surrounding_quotes(path: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def validate_workspace_to_add(path: str) -> Path:
|
||||
def validate_workspace_to_add(path: str, profile: str | Path | None = None) -> Path:
|
||||
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
|
||||
|
||||
When a user explicitly adds a new workspace path, we trust their intent — they
|
||||
@@ -915,10 +1194,10 @@ def validate_workspace_to_add(path: str) -> Path:
|
||||
and users routinely paste those into the Add Space input.
|
||||
"""
|
||||
path = _strip_surrounding_quotes(path)
|
||||
candidate = _resolve_path(path)
|
||||
candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
|
||||
|
||||
access_error = _workspace_access_error(candidate)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# Remote terminal profiles validate workspace existence on the target
|
||||
# machine, not on the WebUI server. Permit target-side paths under
|
||||
@@ -973,6 +1252,7 @@ def safe_resolve_ws(root: Path, requested: str) -> Path:
|
||||
_DIR_FD_OK = os.open in getattr(os, "supports_dir_fd", set())
|
||||
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||
_O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
|
||||
_O_BINARY = getattr(os, "O_BINARY", 0)
|
||||
|
||||
|
||||
def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int:
|
||||
@@ -994,7 +1274,11 @@ def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int:
|
||||
# Windows / no openat: fall back to a plain pathname open. No new race
|
||||
# protection, but no regression vs the prior path-based behaviour, and
|
||||
# symlink creation needs admin on Windows anyway.
|
||||
flags = os.O_RDONLY | (_O_DIRECTORY if want_dir else 0) | _O_NOFOLLOW
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
| (_O_DIRECTORY if want_dir else _O_BINARY)
|
||||
| _O_NOFOLLOW
|
||||
)
|
||||
try:
|
||||
return os.open(str(target), flags)
|
||||
except OSError:
|
||||
@@ -1009,7 +1293,11 @@ def open_anchored_fd(workspace: Path, target: Path, *, want_dir: bool) -> int:
|
||||
for i, part in enumerate(rel_parts):
|
||||
is_last = i == len(rel_parts) - 1
|
||||
want_directory = (not is_last) or want_dir
|
||||
flags = os.O_RDONLY | _O_NOFOLLOW | (_O_DIRECTORY if want_directory else 0)
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
| _O_NOFOLLOW
|
||||
| (_O_DIRECTORY if want_directory else _O_BINARY)
|
||||
)
|
||||
try:
|
||||
nfd = os.open(part, flags, dir_fd=fd)
|
||||
except OSError:
|
||||
@@ -1231,6 +1519,40 @@ def rename_anchored(root: Path, source: Path, dest: Path) -> None:
|
||||
os.close(src_parent_fd)
|
||||
|
||||
|
||||
def _birthtime_ns(lst) -> int | None:
|
||||
"""Return creation time in ns, or None when the platform lacks birthtime."""
|
||||
value = getattr(lst, 'st_birthtime_ns', None)
|
||||
if value is not None:
|
||||
return value
|
||||
value = getattr(lst, 'st_birthtime', None)
|
||||
if value is not None:
|
||||
return int(value * 1_000_000_000)
|
||||
if sys.platform == 'win32':
|
||||
return getattr(lst, 'st_ctime_ns', None)
|
||||
return None
|
||||
|
||||
|
||||
def _browser_timestamp_ns(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def serialize_workspace_entries_for_browser(entries: list[dict] | None) -> list[dict]:
|
||||
payload = []
|
||||
for entry in entries or []:
|
||||
item = dict(entry or {})
|
||||
if 'mtime_ns' in item:
|
||||
item['mtime_ns'] = _browser_timestamp_ns(item.get('mtime_ns'))
|
||||
if 'birthtime_ns' in item:
|
||||
item['birthtime_ns'] = _browser_timestamp_ns(item.get('birthtime_ns'))
|
||||
payload.append(item)
|
||||
return payload
|
||||
|
||||
|
||||
def list_dir(workspace: Path, rel: str='.'):
|
||||
target = safe_resolve_ws(workspace, rel)
|
||||
if not target.is_dir():
|
||||
@@ -1245,6 +1567,8 @@ def list_dir(workspace: Path, rel: str='.'):
|
||||
with follow_symlinks=False (else None); ``reachable`` is False when a
|
||||
follow_symlinks=True stat raised (broken target or symlink loop)."""
|
||||
if is_symlink:
|
||||
# Keep the transport rank aligned with _sort_key_de/_sort_key_p.
|
||||
workspace_sort_rank = 0
|
||||
if raw_link is None:
|
||||
return
|
||||
# A symlink whose follow-stat raised (ELOOP / broken target) can never
|
||||
@@ -1293,8 +1617,10 @@ def list_dir(workspace: Path, rel: str='.'):
|
||||
'path': display_path,
|
||||
'type': 'symlink',
|
||||
'is_dir': False,
|
||||
'workspace_sort_rank': workspace_sort_rank,
|
||||
'target_outside_workspace': True,
|
||||
'mtime_ns': mtime_ns,
|
||||
'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None,
|
||||
}
|
||||
entries.append(entry)
|
||||
else:
|
||||
@@ -1305,8 +1631,10 @@ def list_dir(workspace: Path, rel: str='.'):
|
||||
'type': 'symlink',
|
||||
'target': str(link_target),
|
||||
'is_dir': is_dir,
|
||||
'workspace_sort_rank': workspace_sort_rank,
|
||||
'target_outside_workspace': False,
|
||||
'mtime_ns': mtime_ns,
|
||||
'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None,
|
||||
}
|
||||
if not is_dir:
|
||||
try:
|
||||
@@ -1320,6 +1648,7 @@ def list_dir(workspace: Path, rel: str='.'):
|
||||
entry_path = rel + '/' + name
|
||||
if lstat_result is not None:
|
||||
is_file = stat.S_ISREG(lstat_result.st_mode)
|
||||
workspace_sort_rank = 2 if is_file else 1
|
||||
size = lstat_result.st_size if is_file else None
|
||||
mtime_ns = lstat_result.st_mtime_ns
|
||||
is_dir_entry = stat.S_ISDIR(lstat_result.st_mode)
|
||||
@@ -1327,12 +1656,15 @@ def list_dir(workspace: Path, rel: str='.'):
|
||||
size = None
|
||||
mtime_ns = None
|
||||
is_dir_entry = False
|
||||
workspace_sort_rank = 1
|
||||
entries.append({
|
||||
'name': name,
|
||||
'path': entry_path,
|
||||
'type': 'dir' if is_dir_entry else 'file',
|
||||
'size': size,
|
||||
'mtime_ns': mtime_ns,
|
||||
'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None,
|
||||
'workspace_sort_rank': workspace_sort_rank,
|
||||
})
|
||||
|
||||
if _DIR_FD_OK:
|
||||
@@ -1683,6 +2015,7 @@ def _run_git(args, cwd, timeout=3):
|
||||
r = subprocess.run(
|
||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
return r.stdout.strip() if r.returncode == 0 else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
|
||||
+5
-19
@@ -12,7 +12,6 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import re
|
||||
@@ -21,24 +20,11 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _windows_hide_flags() -> int:
|
||||
"""Win32 ``creationflags`` that hide a short-lived console child's window
|
||||
(``CREATE_NO_WINDOW``) without detaching it, so ``capture_output`` still
|
||||
works. Returns ``0`` on non-Windows — the ``subprocess`` default, a genuine
|
||||
no-op. Mirrors the ``api/updates.py`` pattern; kept local so workspace-git
|
||||
never takes a hard dependency on the optional ``hermes_cli`` package (a
|
||||
standalone/agent-less WebUI must keep full git functionality). See #5692.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
return 0
|
||||
|
||||
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
GIT_TIMEOUT = 5
|
||||
GIT_REMOTE_TIMEOUT = 60
|
||||
@@ -263,7 +249,7 @@ def _run_git(
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=run_env,
|
||||
creationflags=_windows_hide_flags(),
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise GitWorkspaceError("Git command timed out", "timeout") from exc
|
||||
@@ -306,7 +292,7 @@ def _config_names_for_scope(
|
||||
capture_output=True,
|
||||
timeout=GIT_TIMEOUT,
|
||||
env=env,
|
||||
creationflags=_windows_hide_flags(),
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
if result.returncode not in {0, 1}:
|
||||
if ignore_unsupported:
|
||||
|
||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
|
||||
import logging
|
||||
|
||||
from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -21,6 +23,7 @@ def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
|
||||
|
||||
@@ -326,6 +329,7 @@ def find_git_repo_root(workspace: str | Path) -> Path:
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
creationflags=windows_hide_flags(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError("Workspace is not inside a git repository") from exc
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
# QUICK START:
|
||||
# docker compose -f docker-compose.three-container.yml up -d
|
||||
# Open http://localhost:8787 (chat) and http://localhost:9119 (dashboard)
|
||||
# Remote access (issue #6876): echo "HERMES_DASHBOARD_BIND=0.0.0.0" >> .env
|
||||
# and echo "API_SERVER_KEY=<16+ chars>" >> .env, then recreate the stack.
|
||||
# The WebUI reports "Hermes agent is not responding" when the gateway
|
||||
# listener is down — API_SERVER_KEY must be set for port 8642 to open.
|
||||
#
|
||||
# This extends the two-container setup with the Hermes Dashboard for
|
||||
# monitoring agent activity, sessions, and resource usage.
|
||||
@@ -39,7 +43,10 @@ services:
|
||||
container_name: hermes-agent
|
||||
command: gateway run
|
||||
ports:
|
||||
- "127.0.0.1:8642:8642"
|
||||
# Bind to loopback by default. For remote access (issue #6876), set
|
||||
# HERMES_AGENT_BIND=0.0.0.0 in .env — the gateway requires
|
||||
# API_SERVER_KEY (>=16 chars) to open the listener at all.
|
||||
- "${HERMES_AGENT_BIND:-127.0.0.1}:8642:8642"
|
||||
volumes:
|
||||
# Persist config, state, sessions, skills, memory across restarts
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
@@ -51,6 +58,15 @@ services:
|
||||
# Defaults to 1000 to match WANTED_UID/WANTED_GID in the webui service.
|
||||
- HERMES_UID=${UID:-1000}
|
||||
- HERMES_GID=${GID:-1000}
|
||||
# Gateway API server (port 8642). The WebUI's Tasks/cron health probe
|
||||
# reaches this listener via HERMES_API_URL below. The agent only starts
|
||||
# the listener when API_SERVER_KEY is a usable value (>=16 chars); set
|
||||
# it in .env to enable the gateway API. Without a key the gateway daemon
|
||||
# still runs (messaging + cron) but port 8642 stays unbound and the
|
||||
# WebUI reports "Gateway endpoint not reachable".
|
||||
- API_SERVER_ENABLED=true
|
||||
- API_SERVER_HOST=0.0.0.0
|
||||
- API_SERVER_KEY=${API_SERVER_KEY:-}
|
||||
# Bind-mount permission handling for the agent — narrow set of overrides.
|
||||
# NOTE: The agent's HERMES_HOME_MODE applies to the HERMES_HOME *directory*
|
||||
# mode (default 0700) — NOT to credential files like the WebUI's variant.
|
||||
@@ -74,7 +90,11 @@ services:
|
||||
container_name: hermes-dashboard
|
||||
command: dashboard --host 0.0.0.0 --insecure
|
||||
ports:
|
||||
- "127.0.0.1:9119:9119"
|
||||
# Loopback-only by default (the dashboard runs --insecure). To reach the
|
||||
# dashboard from another machine (issue #6876), set
|
||||
# HERMES_DASHBOARD_BIND=0.0.0.0 in .env and put it behind auth / a
|
||||
# reverse proxy.
|
||||
- "${HERMES_DASHBOARD_BIND:-127.0.0.1}:9119:9119"
|
||||
volumes:
|
||||
- hermes-home:/home/hermes/.hermes
|
||||
environment:
|
||||
@@ -132,14 +152,18 @@ services:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Forward the API server key so the WebUI's gateway health probe can
|
||||
# authenticate (must match the agent's API_SERVER_KEY above).
|
||||
- HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-}
|
||||
# Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env
|
||||
# (required if you expose the port beyond 127.0.0.1).
|
||||
- HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-}
|
||||
# NOTE: When using bind-mount volumes shared across containers, ALL containers
|
||||
# that write to the same host directory must run as the same UID/GID.
|
||||
# If hermes-agent initialises the state dir as root (UID 0), hermes-webui
|
||||
# will get a PermissionError accessing those paths — including a crash on every
|
||||
# HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID
|
||||
# to match the agent container's UID, or use a named Docker volume (preferred).
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
# Bind-mount permission handling for the WebUI (fixes #1389, #1399).
|
||||
# NOTE: WebUI's HERMES_HOME_MODE is a credential-file threshold (allow
|
||||
# group bits on .env/.signing_key/etc.), DIFFERENT from the agent's
|
||||
|
||||
@@ -67,6 +67,15 @@ services:
|
||||
# The agent image's entrypoint already supports usermod remapping.
|
||||
- HERMES_UID=${UID:-1000}
|
||||
- HERMES_GID=${GID:-1000}
|
||||
# Gateway API server (port 8642). The WebUI's Tasks/cron health probe
|
||||
# reaches this listener via HERMES_API_URL below. The agent only starts
|
||||
# the listener when API_SERVER_KEY is a usable value (>=16 chars); set
|
||||
# it in .env to enable the gateway API. Without a key the gateway daemon
|
||||
# still runs (messaging + cron) but port 8642 stays unbound and the
|
||||
# WebUI reports "Gateway endpoint not reachable".
|
||||
- API_SERVER_ENABLED=true
|
||||
- API_SERVER_HOST=0.0.0.0
|
||||
- API_SERVER_KEY=${API_SERVER_KEY:-}
|
||||
# Bind-mount permission handling for the agent — narrow set of overrides.
|
||||
# NOTE: The agent's HERMES_HOME_MODE applies to the HERMES_HOME *directory*
|
||||
# mode (default 0700) — NOT to credential files like the WebUI's variant.
|
||||
@@ -117,8 +126,12 @@ services:
|
||||
# echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env
|
||||
- WANTED_UID=${UID:-1000}
|
||||
- WANTED_GID=${GID:-1000}
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
# Forward the API server key so the WebUI's gateway health probe can
|
||||
# authenticate (must match the agent's API_SERVER_KEY above).
|
||||
- HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-}
|
||||
# Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env
|
||||
# (required if you expose the port beyond 127.0.0.1).
|
||||
- HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-}
|
||||
# Bind-mount permission handling for the WebUI (fixes #1389, #1399).
|
||||
# NOTE: WebUI's HERMES_HOME_MODE is a credential-file threshold (allow
|
||||
# group bits on .env/.signing_key/etc.), DIFFERENT from the agent's
|
||||
|
||||
+3
-2
@@ -42,8 +42,9 @@ services:
|
||||
- HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui
|
||||
# Default workspace directory shown on first launch
|
||||
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
|
||||
# Optional: set a password for remote access
|
||||
# - HERMES_WEBUI_PASSWORD=your-secret-password
|
||||
# Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env
|
||||
# (required if you expose the port beyond 127.0.0.1).
|
||||
- HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-}
|
||||
#
|
||||
# Bind-mount permission handling (fixes #1389, #1399):
|
||||
# When you mount an EXISTING ~/.hermes directory (the common case),
|
||||
|
||||
+70
-26
@@ -57,17 +57,31 @@ if [ ! -d "$itdir" ]; then error_exit "Failed to create $itdir"; fi
|
||||
# logic: if not set and file exists, use file value, else use default. Create file for persistence when the container is re-run
|
||||
# reasoning: needed when using docker compose as the file will exist in the stopped container, and changing the value from environment variables or configuration file must be propagated from the root init phase to the hermeswebui runtime phase
|
||||
it=$itdir/hermeswebui_user_uid
|
||||
if [ -z "${WANTED_UID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_UID=$(cat $it); fi
|
||||
# Remember where the value came from. Auto-detection must never overwrite an
|
||||
# identity the operator chose explicitly — including the legitimate value 1024,
|
||||
# which is also the built-in default and was therefore indistinguishable from
|
||||
# "unset" (#7027). The source is persisted next to the value because `su` drops
|
||||
# the environment when this script re-enters as the runtime user, so an explicit
|
||||
# choice would otherwise look like a detected one on the second pass.
|
||||
it_source=$itdir/hermeswebui_user_uid_source
|
||||
_wanted_uid_source=""
|
||||
if [ -n "${WANTED_UID+x}" ]; then
|
||||
_wanted_uid_source=explicit
|
||||
elif [ -f $it ]; then
|
||||
WANTED_UID=$(cat $it)
|
||||
if [ -f $it_source ]; then _wanted_uid_source=$(cat $it_source); fi
|
||||
fi
|
||||
# Auto-detect from mounted volumes if still unset (#569, #668).
|
||||
# Auto-detect from mounted volumes if still unset (#569, #668, #7027).
|
||||
# On macOS, host UIDs start at 501. Using the wrong UID means the container
|
||||
# user cannot read the bind-mounted files, making the workspace appear empty.
|
||||
# In two-container setups (hermes-agent + hermes-webui), the shared hermes-home
|
||||
# volume may be owned by the agent container's UID — detect from there first.
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Priority 1: hermes-home shared volume — covers two-container Zeabur/Compose setups (#668)
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ "$_wanted_uid_source" != "explicit" ] && { [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; }; then
|
||||
# Priority 1: the configured state dir first (#7027) — in a single-container
|
||||
# deploy it is a bind mount by definition, so its owner is the host identity
|
||||
# the container has to match — then the hermes-home shared volume, which
|
||||
# covers two-container Zeabur/Compose setups (#668)
|
||||
for _probe_dir in "${HERMES_WEBUI_STATE_DIR:-/app/data}" "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_uid=$(stat -c '%u' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
@@ -78,8 +92,10 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount — the standard single-container mount point
|
||||
if [ "$_wanted_uid_source" != "explicit" ] && { [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; }; then
|
||||
# Priority 2: /workspace bind-mount — a useful signal when it is actually
|
||||
# bind-mounted, but a stock image ships /workspace owned by the build-time
|
||||
# 1024, which says nothing about the host, so it stays below the state dir (#7027)
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_uid=$(stat -c '%u' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_uid" ] && [ "$_detected_uid" != "0" ]; then
|
||||
@@ -90,16 +106,22 @@ if [ -z "${WANTED_UID+x}" ] || [ "${WANTED_UID}" = "1024" ]; then
|
||||
fi
|
||||
WANTED_UID=${WANTED_UID:-1024}
|
||||
write_privtmpfile $it "$WANTED_UID"
|
||||
write_privtmpfile $it_source "${_wanted_uid_source:-detected}"
|
||||
echo "-- WANTED_UID: \"${WANTED_UID}\""
|
||||
|
||||
it=$itdir/hermeswebui_user_gid
|
||||
if [ -z "${WANTED_GID+x}" ]; then
|
||||
if [ -f $it ]; then WANTED_GID=$(cat $it); fi
|
||||
it_source=$itdir/hermeswebui_user_gid_source
|
||||
_wanted_gid_source=""
|
||||
if [ -n "${WANTED_GID+x}" ]; then
|
||||
_wanted_gid_source=explicit
|
||||
elif [ -f $it ]; then
|
||||
WANTED_GID=$(cat $it)
|
||||
if [ -f $it_source ]; then _wanted_gid_source=$(cat $it_source); fi
|
||||
fi
|
||||
# Auto-detect GID from mounted volumes to match (#569, #668)
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 1: hermes-home shared volume
|
||||
for _probe_dir in "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
# Auto-detect GID from mounted volumes to match (#569, #668, #7027)
|
||||
if [ "$_wanted_gid_source" != "explicit" ] && { [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; }; then
|
||||
# Priority 1: configured state dir first, then the hermes-home shared volume
|
||||
for _probe_dir in "${HERMES_WEBUI_STATE_DIR:-/app/data}" "/home/hermeswebui/.hermes" "$HERMES_HOME" "/opt/data"; do
|
||||
if [ -d "$_probe_dir" ]; then
|
||||
_detected_gid=$(stat -c '%g' "$_probe_dir" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
@@ -110,8 +132,8 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
# Priority 2: /workspace bind-mount
|
||||
if [ "$_wanted_gid_source" != "explicit" ] && { [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; }; then
|
||||
# Priority 2: /workspace bind-mount — lower priority for the same reason as UID (#7027)
|
||||
if [ -d "/workspace" ]; then
|
||||
_detected_gid=$(stat -c '%g' "/workspace" 2>/dev/null || echo "")
|
||||
if [ -n "$_detected_gid" ] && [ "$_detected_gid" != "0" ]; then
|
||||
@@ -122,6 +144,7 @@ if [ -z "${WANTED_GID+x}" ] || [ "${WANTED_GID}" = "1024" ]; then
|
||||
fi
|
||||
WANTED_GID=${WANTED_GID:-1024}
|
||||
write_privtmpfile $it "$WANTED_GID"
|
||||
write_privtmpfile $it_source "${_wanted_gid_source:-detected}"
|
||||
echo "-- WANTED_GID: \"${WANTED_GID}\""
|
||||
|
||||
echo "== Most Environment variables set"
|
||||
@@ -146,7 +169,26 @@ load_env() {
|
||||
obfuscate_part="${ENV_OBFUSCATE_PART}"
|
||||
if [ -f "$tocheck" ]; then
|
||||
echo "-- Loading environment variables from $tocheck (overwrite existing: $overwrite_if_different) (ignorelist: $ignore_list) (obfuscate: $obfuscate_part)"
|
||||
while IFS='=' read -r key value; do
|
||||
# Read whole lines, then split at the FIRST '=' only. Splitting on every
|
||||
# '=' (IFS='=' read -r key value) makes read drop a single trailing '='
|
||||
# from values, corrupting secrets such as openssl rand -base64 32 output
|
||||
# (44 chars ending in '=').
|
||||
while IFS= read -r key; do
|
||||
# Skip empty lines and comment lines.
|
||||
case "$key" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
if [[ "$key" == *=* ]]; then
|
||||
# Split at the FIRST '=' only. Derive value before shortening key,
|
||||
# since ${key%%=*} mutates key. Reusing the existing 'key' read
|
||||
# target (rather than a new 'line' var) avoids silently clobbering a
|
||||
# user env var named 'line' on the post-su reload.
|
||||
value=${key#*=}
|
||||
key=${key%%=*}
|
||||
else
|
||||
# Line without '=': whole line is the key with an empty value.
|
||||
value=
|
||||
fi
|
||||
doit=false
|
||||
# checking if the key is in the ignorelist
|
||||
for i in $ignore_list; do
|
||||
@@ -430,14 +472,17 @@ else
|
||||
# `egg_info` build step, however, touches `hermes_agent.egg-info/` inside the
|
||||
# source tree even under PEP 517 build isolation, which `EROFS`-fails on a
|
||||
# `:ro` mount and (under `set -e`) kills startup of every multi-container
|
||||
# deploy. Stage the source into a writable tmpfs copy so the build can write
|
||||
# its metadata side-by-side without touching the underlying mount.
|
||||
# deploy. Stage the source into a writable directory so the editable install
|
||||
# can write its metadata without touching the underlying mount.
|
||||
#
|
||||
# The staged copy lives under /app/ (persistent across container restarts)
|
||||
# because the editable install records this path in a .pth file — Python
|
||||
# imports resolve through it at runtime. Placing it alongside the venv gives
|
||||
# it the same lifecycle as .deps_installed: both survive restarts and both
|
||||
# are lost on container recreation (fresh boot re-stages automatically).
|
||||
#
|
||||
# The copy excludes any pre-baked `*.egg-info` / `build` / `dist` artifacts
|
||||
# to avoid the timestamp-update path setuptools takes when one is present,
|
||||
# and `--reflink=auto` makes the copy near-free on overlay2/btrfs where
|
||||
# supported. We rebuild on every container start (the agent source can
|
||||
# change across volume re-init); cost is one rsync of ~10MB of Python source.
|
||||
# to avoid the timestamp-update path setuptools takes when one is present.
|
||||
#
|
||||
# NB: `rsync -a` / `cp -a` preserve the source tree's mode bits, so a `:ro`
|
||||
# source mounted mode 555 leaves the staged copy also mode 555. setuptools
|
||||
@@ -445,7 +490,7 @@ else
|
||||
# with "Permission denied" even though `_stage_src` itself was created
|
||||
# writable by hermeswebui. Re-add owner-write on the staged tree after the
|
||||
# copy so the build dir is genuinely writable, not just owned by us.
|
||||
_stage_src="/tmp/hermes-agent-build"
|
||||
_stage_src="/app/hermes-agent-src"
|
||||
rm -rf "$_stage_src"
|
||||
mkdir -p "$_stage_src"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
@@ -466,9 +511,8 @@ else
|
||||
fi
|
||||
chmod -R u+w "$_stage_src" \
|
||||
|| error_exit "Failed to make staged hermes-agent source writable (rsync/cp preserved :ro mount perms)"
|
||||
uv pip install "$_stage_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org \
|
||||
uv pip install -e "$_stage_src[all]" --trusted-host pypi.org --trusted-host files.pythonhosted.org \
|
||||
|| error_exit "Failed to install hermes-agent's requirements"
|
||||
rm -rf "$_stage_src"
|
||||
else
|
||||
echo ""
|
||||
echo "!! WARNING: hermes-agent source not found."
|
||||
|
||||
+10
-3
@@ -23,6 +23,10 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
|
||||
## Runtime, durability, and state contracts
|
||||
|
||||
- [`docs/remote-workspaces.md`](remote-workspaces.md):
|
||||
architecture contract for remote terminal workspaces (SSH/Docker), target-side
|
||||
POSIX path preservation against macOS synthetic firmlink expansion, and
|
||||
per-profile isolation boundaries.
|
||||
- [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md):
|
||||
proposed consistency rules for current WebUI streaming, recovery, replay,
|
||||
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
|
||||
@@ -72,9 +76,12 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
stream `GET /api/sessions/{session_id}/events` (#4812). Distinct from the
|
||||
existing global session-list stream `GET /api/sessions/events`. Start here for
|
||||
any work that touches per-session SSE, `Last-Event-ID` replay, or session
|
||||
lifecycle event delivery. The Phase 1 server contract is now shipped; the RFC
|
||||
remains the vocabulary reference while broader client and platform claims stay
|
||||
behind the recorded proof gates.
|
||||
lifecycle event delivery. The Phase 1 **server route and journal relay** for
|
||||
`GET /api/sessions/{session_id}/events` are implemented; broader client,
|
||||
platform, and semantic-taxonomy claims in the RFC remain behind the recorded
|
||||
proof gates. Prefer the RFC's **Authoritative emitted events** table (live
|
||||
`/api/chat/stream` wire names) over the aspirational semantic taxonomy when
|
||||
writing clients against current source.
|
||||
|
||||
When a change touches streaming, recovery, replay, compression, context
|
||||
reconstruction, cancellation, approval/clarify, session metadata, or run state,
|
||||
|
||||
@@ -271,6 +271,125 @@ overrides and returns schema defaults. Extension-owned storage uses a separate
|
||||
browser-local namespace, and clearing storage removes that namespace without
|
||||
changing settings.
|
||||
|
||||
### Cooperative extension identity
|
||||
|
||||
An injected extension can claim a stable browser-page identity together with its
|
||||
existing settings and storage accessors:
|
||||
|
||||
```js
|
||||
const ext = window.hermesExt.register("desktop-companion");
|
||||
if (ext) {
|
||||
ext.settings.set("show_badge", false);
|
||||
ext.storage.set("lastPanel", "settings");
|
||||
}
|
||||
```
|
||||
|
||||
`register(id)` trims the ID and succeeds only for an ID in the effective-enabled,
|
||||
Core-sanitized manifest inventory that was present at the initial page boot. It
|
||||
returns `null` for empty, malformed, unknown, or untrusted IDs without creating
|
||||
settings or storage state. Re-registering the same ID in one page returns the
|
||||
same handle object; different IDs receive different handles. The handle exposes
|
||||
only the canonical `id`, `settings`, `storage`, and `events` fields. Settings and
|
||||
storage are backed by the same factories used by the legacy accessors.
|
||||
|
||||
Manifest enable/disable changes still take effect after a WebUI reload. A handle
|
||||
already created in the current page may remain cached across a status refresh;
|
||||
this is expected and does not implement runtime unload. This identity is a
|
||||
cooperative attribution convenience for extensions sharing the page—not a
|
||||
sandbox, isolation mechanism, capability or permission system, or security
|
||||
boundary. Extensions still execute with the full WebUI session authority
|
||||
described above.
|
||||
|
||||
### Custom Configure editors
|
||||
|
||||
Extensions with structured configuration that does not fit scalar
|
||||
`settings_schema` fields can register one custom editor entry point on their
|
||||
scoped E0 settings handle:
|
||||
|
||||
```js
|
||||
const ext = window.hermesExt?.register?.("dictionary-manager");
|
||||
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
|
||||
openDictionaryManager({ opener, restoreFocus });
|
||||
});
|
||||
```
|
||||
|
||||
`registerConfigure(handler)` is available only through a valid boot-trusted E0
|
||||
handle. It does not require `settings_schema` or extension-owned storage. The
|
||||
first handler registered for an extension wins; duplicates return `null` and do
|
||||
not replace it. A successful registration returns an idempotent unregister
|
||||
function.
|
||||
|
||||
Core shows one **Configure** button only for the extension's current
|
||||
effective-enabled row under **Settings → Extensions → Installed**. Diagnostics
|
||||
never shows the button. Late registration updates an already-mounted Installed
|
||||
row. Disable or uninstall status removes the entry point immediately; uninstall
|
||||
followed by a same-ID reinstall cannot revive the old page-local handler until a
|
||||
full WebUI reload injects and registers the new extension script.
|
||||
|
||||
Core enters a pending state before invoking the handler and passes the activated
|
||||
button as `opener` plus a one-shot `restoreFocus()` callback. The extension owns
|
||||
its dialog, validation, persistence, and close lifecycle. It must either call
|
||||
`restoreFocus()` when its UI closes or return a thenable whose settlement means
|
||||
the Configure UI is closed. Both paths end pending and converge on Core's
|
||||
one-shot focus restoration. A synchronous non-thenable handler remains disabled
|
||||
while unsettled. If it never calls `restoreFocus()`, it remains disabled until reload.
|
||||
Core does not guess dialog lifetime with a timeout.
|
||||
|
||||
Synchronous throws, thenable-access failures, and asynchronous rejections are
|
||||
isolated, logged with the extension ID, and surfaced as a generic failure. Focus
|
||||
returns to the connected opener when possible, otherwise to the current visible
|
||||
Configure button or the Installed tab without forcing navigation. The hook does
|
||||
not let an extension return DOM for Core to render, add a backend settings route,
|
||||
or change the trusted same-origin extension model.
|
||||
|
||||
### Turn lifecycle events
|
||||
|
||||
A registered extension can react when a session turn observed by the current page
|
||||
starts or reaches a terminal state:
|
||||
|
||||
```js
|
||||
const ext = window.hermesExt.register("desktop-companion");
|
||||
const stop = ext && ext.events.on("turn:complete", event => {
|
||||
console.log(event.sessionId, event.streamId, event.status);
|
||||
});
|
||||
|
||||
// Remove the listener when the extension no longer needs it.
|
||||
if (stop) stop();
|
||||
```
|
||||
|
||||
The supported event types are `turn:start`, `turn:complete`, `turn:error`, and
|
||||
`turn:cancel`. The frozen event object always contains `type`, `sessionId`,
|
||||
`streamId`, and `timestamp` (Unix seconds). Start events also contain
|
||||
`startedAt`; terminal events contain `endedAt` and may contain `status`.
|
||||
`events.on(type, handler)` returns an idempotent unsubscribe function, or `null`
|
||||
for an unsupported type or non-function handler. An exception in one extension
|
||||
listener is logged and does not prevent other listeners or the chat UI from
|
||||
continuing.
|
||||
|
||||
The version-1 mapping is deliberately small: attaching a confirmed live session
|
||||
stream emits `turn:start`; SSE `done` emits `turn:complete`; SSE `cancel` and
|
||||
application errors classified as `cancelled` or `interrupted` emit
|
||||
`turn:cancel`; other application errors and an unrecoverable live-stream
|
||||
connection failure emit `turn:error`. Transport-only `stream_end` does not emit
|
||||
a second terminal event.
|
||||
|
||||
Core suppresses reconnect duplicates so a recently observed
|
||||
`sessionId`/`streamId` pair produces at most one start event and one terminal
|
||||
event. `sessionId` is the owner of the original stream; server-side compression
|
||||
or session rotation at completion does not change that lifecycle identity.
|
||||
Terminal callbacks run after Core has cleared the original stream ownership,
|
||||
applied the current session's immediate terminal transcript projection, and
|
||||
returned the owned pane to its idle state. They do not wait for unrelated
|
||||
follow-up work such as notifications, queued turns, or optional recovery
|
||||
refreshes.
|
||||
|
||||
This is a page-local, live-stream notification surface. It does not replay
|
||||
history, report turns that finish without this page observing their stream, or
|
||||
provide token, tool, approval, or metrics events. It follows the same cooperative
|
||||
trust model as `register(id)` and adds no sandbox or permission boundary. A
|
||||
durable or global agent event stream is a separate Core contract. Ephemeral
|
||||
`/btw` answer streams are not included in this version.
|
||||
|
||||
## URL rules
|
||||
|
||||
Injected asset URLs are deliberately restricted:
|
||||
|
||||
+18
-9
@@ -96,16 +96,25 @@ unless the code and tests prove it is implemented.
|
||||
|
||||
## Typography and content
|
||||
|
||||
Use split typography intentionally:
|
||||
Use three explicit font tokens:
|
||||
|
||||
- assistant prose uses the same system sans stack as the rest of the UI by
|
||||
default (`--font-ui` in `static/style.css`),
|
||||
- editorial serif assistant prose is historical/proposal or skin-scoped only;
|
||||
do not reintroduce a global assistant serif without explicit design approval
|
||||
plus code and test evidence,
|
||||
- user bubbles and functional UI also use the system sans stack unless a scoped
|
||||
skin intentionally overrides typography,
|
||||
- monospace is for code, file paths, commands, tool names, and compact metadata.
|
||||
- `--font-ui`: shell chrome, controls, composer, labels, and ordinary UI text
|
||||
- `--font-conversation`: user/assistant message prose; by default this is
|
||||
`var(--font-ui)` in `static/style.css`
|
||||
- `--font-mono`: code, file paths, command lines, tool payloads, technical logs,
|
||||
and terminal output
|
||||
|
||||
Use semantic tokens for typography. Keep prose on `--font-conversation` by
|
||||
default so it tracks `--font-ui` whenever a skin intentionally retunes UI type.
|
||||
Override `--font-conversation` only for a skin that intentionally wants a
|
||||
distinct prose face.
|
||||
Conversation prose must remain the system sans by default. Do not introduce a
|
||||
global conversation serif through the `--font-conversation` token or
|
||||
selector-level overrides without explicit design approval plus code and test
|
||||
evidence; keep distinct editorial prose typography explicitly opt-in or
|
||||
skin-scoped.
|
||||
Avoid hard-coding selector-level font stacks when a token already carries the
|
||||
intent.
|
||||
|
||||
Keep scale tight. Avoid introducing near-duplicate one-off font sizes, colors,
|
||||
radius values, or spacing values when an existing token works.
|
||||
|
||||
@@ -49,6 +49,50 @@ every new browser turn. The browser only receives a compact status event
|
||||
(`source`, `label`, message count, compaction metadata, and redacted errors),
|
||||
never the prefill message bodies.
|
||||
|
||||
## Session title generation
|
||||
|
||||
Hermes WebUI derives a provisional session title from the first user message
|
||||
and, after the first response, may call an LLM to generate a better title
|
||||
(and periodically refresh it for long sessions).
|
||||
|
||||
For structured messages containing text and native images, title generation
|
||||
uses the user text without flattening or modifying the stored message. Title
|
||||
comparison and title-model inputs remove the internal `[Workspace::v1: ...]`
|
||||
prefix and one terminal `[Attached files: ...]` or
|
||||
`[Attached files for this steer: ...]` suffix separated by a blank line.
|
||||
For structured content, this cleanup applies to the first text part that
|
||||
provides title content. Literal legacy `[Workspace: ...]` text and later text
|
||||
parts remain unchanged. Initial generation, explicit regeneration, and adaptive
|
||||
refresh use this title-specific cleanup.
|
||||
|
||||
Background generation requires user text and a substantive assistant response.
|
||||
It recognizes the sanitized provisional title as well as the existing raw
|
||||
placeholder, so internal metadata does not make an image-containing turn look
|
||||
manually titled. Image-only or metadata-only content does not provide title
|
||||
text. Existing manual-title protection and the title-generation setting still
|
||||
apply; this cleanup does not rewrite the transcript or native image parts.
|
||||
|
||||
Automatic title-generation LLM calls honor the active Hermes profile's
|
||||
`auxiliary.title_generation.enabled` setting (default: `true`):
|
||||
|
||||
```yaml
|
||||
auxiliary:
|
||||
title_generation:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
When disabled:
|
||||
|
||||
- the provisional first-message title stays in place and is never replaced
|
||||
or overwritten by an automatic LLM call or local fallback;
|
||||
- the periodic adaptive refresh is skipped;
|
||||
- the explicit "regenerate title" action returns a
|
||||
`title_generation_disabled` response instead of calling a title model.
|
||||
|
||||
The WebUI's `auto_title_refresh_every` setting remains a separate control for
|
||||
periodic refreshes of already-generated titles; it does not re-enable
|
||||
automatic generation when the auxiliary flag is off.
|
||||
|
||||
## Gateway-backed browser chat
|
||||
|
||||
By default, browser chat runs through WebUI's in-process legacy runtime. Advanced
|
||||
@@ -75,6 +119,21 @@ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
||||
|
||||
Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card.
|
||||
|
||||
When YOLO is enabled for a gateway-backed browser session, WebUI approves every
|
||||
approval already parked for that session: Runs API prompts are relayed by their
|
||||
exact `run_id` and mirror token, and local/no-run waiters are all released. It
|
||||
then automatically answers later Runs API approval requests while the WebUI
|
||||
session flag remains active. The flag is committed only after every currently
|
||||
parked remote relay succeeds; a later prompt that races that unconfirmed drain
|
||||
remains visible instead of being speculatively auto-approved. The handoff is
|
||||
also shared with local approval admission: a local waiter arriving after
|
||||
the current drain snapshot waits for the same session handoff and is released
|
||||
immediately if YOLO has committed, rather than being parked behind an enabled
|
||||
session. This is client-managed compatibility behavior: the current Runs API has
|
||||
no session-YOLO toggle, so a request briefly reaches the approval boundary before WebUI answers
|
||||
it, and Agent-owned policy such as unrestricted computer-use mode is unchanged.
|
||||
Native API session YOLO is tracked in [Hermes Agent PR #61946](https://github.com/NousResearch/hermes-agent/pull/61946).
|
||||
|
||||
`HERMES_WEBUI_CHAT_BACKEND` is intentionally strict: only `gateway`,
|
||||
`api_server`, or `api-server` enable the bridge. Generic truthy values such as
|
||||
`1` or `true` are ignored so existing deployments do not change execution
|
||||
|
||||
@@ -41,10 +41,109 @@ helpers are genuinely shared code.
|
||||
| `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. |
|
||||
| `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. |
|
||||
| `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. |
|
||||
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
|
||||
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also reads the `messages` table directly (see [state.db message content encoding](#statedb-message-content-encoding) for the storage-format coupling that creates) and opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
|
||||
| `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. |
|
||||
| `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. |
|
||||
|
||||
## state.db message content encoding
|
||||
|
||||
`api/models.py` reads the agent's `messages` table with its own SQL, so it also
|
||||
depends on how hermes-agent *encodes* that table, not only on its schema. This
|
||||
is a storage-format coupling and belongs with the `runtime_session_state`
|
||||
dependency class above.
|
||||
|
||||
`hermes_state` stores list/dict message content (multimodal parts) as a
|
||||
sentinel-prefixed JSON string, because sqlite3 binds only scalars:
|
||||
|
||||
```
|
||||
_CONTENT_JSON_PREFIX = "\x00json:" # hermes_state.py
|
||||
```
|
||||
|
||||
It provides `_decode_content()` to reverse this. Any WebUI read path that
|
||||
projects that column must apply an equivalent decode; a raw read hands the
|
||||
frontend an encoded string that no reader recognises, and an image part's
|
||||
base64 data URI then renders as literal transcript text.
|
||||
|
||||
### WebUI decoding contract
|
||||
|
||||
`_decode_state_db_content()` in `api/models.py` is the single decode point. It
|
||||
is deliberately narrower than the agent's own decoder, because the WebUI can
|
||||
only accept shapes the rest of its pipeline already renders:
|
||||
|
||||
| Input | Result | Why |
|
||||
| --- | --- | --- |
|
||||
| Sentinel + list with non-whitespace text and only valid image parts | decoded `list` | `msgContent()` joins the text parts, so the row renders its text |
|
||||
| Sentinel + image-only list, or text that is empty/whitespace | unchanged string | `msgContent()` discards image parts, so `_messageIsRenderable()` would hide the row with no error |
|
||||
| Sentinel + list containing a malformed image part | unchanged string | a part must carry a valid per-type payload, not just a matching `type` |
|
||||
| Sentinel + dict or scalar root | unchanged string | a dict reaches `_getCachedRender()`, and `_renderCacheKey()` calls `text.slice()` on it, blanking the turn |
|
||||
| Sentinel + `NaN`/`Infinity`/overflowed float | unchanged string | Python emits them, browser `JSON.parse()` rejects the whole `/api/session` payload |
|
||||
| Sentinel + unsupported part shapes | unchanged string | `input_text`, `output_text`, scalar and unknown parts are dropped by the JS readers, so decoding them would silently lose content that is visible today |
|
||||
| Anything without the sentinel | unchanged | non-sentinel content is not this contract's concern |
|
||||
|
||||
Supported parts are `{"type": "text", "text": <str>}` plus image parts whose
|
||||
payload validates for their type: `image_url` with a non-empty URL (string or
|
||||
`{"url": ...}`), `input_image` with a URL or `file_id`, and `image` with a
|
||||
`base64` source carrying `data` and `media_type` or a `url` source. At least one
|
||||
text part must contain non-whitespace text.
|
||||
|
||||
**Image parts do not render from this projection.** The shared JS readers drop
|
||||
them, and the state.db projection supplies no `attachments`. Decoding a
|
||||
text-and-image row shows its text and keeps the base64 payload out of the DOM;
|
||||
it does not display the image. Rendering images from state.db rows would need a
|
||||
shared inline-image projection first, at which point image-only lists could be
|
||||
accepted too.
|
||||
|
||||
Widening the accepted schema requires teaching every shared content reader
|
||||
through one extractor first; until then unsupported shapes must keep falling
|
||||
back to the raw string.
|
||||
|
||||
### Consequences for identity and bounded reads
|
||||
|
||||
Decoding changes the runtime type of `content`, so every consumer that derives
|
||||
an identity from it must agree on one representation:
|
||||
|
||||
- Every key -- merge, dedup, content, visible and the fuzzy fallback -- derives
|
||||
content identity through `_content_identity_for_key()`. Non-list values key
|
||||
exactly as on master, `str(content or "")`. Non-empty lists get an
|
||||
**out-of-band** tuple identity, so no message body can compare equal to one:
|
||||
an in-band string marker would be forgeable by a scalar that contains it.
|
||||
Two rich turns sharing visible text and timestamp stay distinct when their
|
||||
images differ.
|
||||
- Fuzzy duplicate matching is text-only. Structured identities match by exact
|
||||
identity or not at all, so a rich row can never fuzzy-match a scalar.
|
||||
- The merge key cache never writes a key component back into message content.
|
||||
To avoid re-serialising large payloads for every key it instead memoises the
|
||||
canonical serialisation per content object, scoped to one
|
||||
`merge_session_messages_append_only()` call.
|
||||
- The multimodal mirror bridge pairs one rich image-bearing row with one scalar
|
||||
mirror only. `require_image_parts` and `require_scalar_mirror` are mutually
|
||||
exclusive so rich-to-rich pairing cannot occur.
|
||||
- Every read path that projects the `content` column applies the decoder, so
|
||||
keys derived on one path cannot disagree with keys derived on another. There
|
||||
are exactly three such call sites:
|
||||
|
||||
| Call site | Role |
|
||||
| --- | --- |
|
||||
| `_project_state_db_message()` | canonical row projection, shared by the transcript read and the regeneration tail |
|
||||
| `get_state_db_session_message_keys_before_timestamp()` | bounded prefix keys |
|
||||
| `get_state_db_regeneration_tail_snapshot()` | regeneration prefix keys |
|
||||
|
||||
If prefix keys stayed encoded while the projected tail was decoded, the
|
||||
prefix/tail collision proof could miss a genuine repeated recovered turn and
|
||||
`_bounded_tail_snapshot_if_safe` would reject the bounded path, reading the
|
||||
entire transcript during regeneration.
|
||||
|
||||
Two nearby paths deliberately need no decode.
|
||||
`get_state_db_session_message_prefix_summary()` projects only timestamp
|
||||
counts and never selects `content`. State-db sidecar reconstruction
|
||||
(`_sync_sidecar_from_state_db_if_newer()`) sources its rows through
|
||||
`get_state_db_session_messages()`, so it inherits the canonical decoded
|
||||
projection rather than reading the column itself.
|
||||
|
||||
When session state moves behind hermes-agent endpoints, this decode should move
|
||||
with it: the agent should return structured content over the API and the WebUI
|
||||
should stop depending on the sentinel format at all.
|
||||
|
||||
## Replacement contract
|
||||
|
||||
### Existing endpoint candidates
|
||||
|
||||
@@ -193,6 +193,22 @@ cp .env.docker.example .env
|
||||
docker compose -f docker-compose.two-container.yml up -d
|
||||
```
|
||||
|
||||
The compose files forward `API_SERVER_KEY` from `.env` into the `hermes-agent`
|
||||
container. The agent only starts the gateway API listener (port 8642) when
|
||||
`API_SERVER_KEY` is a usable value (>=16 chars) — `API_SERVER_ENABLED` alone
|
||||
does nothing. Without a key, the gateway daemon still runs but port 8642 stays
|
||||
unbound and the WebUI keeps showing **"Gateway endpoint not reachable"**. To
|
||||
enable scheduled ticking and the green gateway pill, set a long random string
|
||||
in `.env`:
|
||||
|
||||
```bash
|
||||
echo "API_SERVER_KEY=$(openssl rand -hex 24)" >> .env
|
||||
docker compose -f docker-compose.two-container.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
The compose file forwards the same value to the WebUI as
|
||||
`HERMES_WEBUI_GATEWAY_API_KEY`, so the health probe authenticates automatically.
|
||||
|
||||
The three-container layout adds the dashboard but is otherwise the same shape. If you must stay single-container, you can run `hermes gateway` inside the container as a long-lived background process, but the compose split is sturdier.
|
||||
|
||||
If you maintain a custom compose file, make sure the **WebUI service** points at
|
||||
@@ -452,6 +468,44 @@ services:
|
||||
|
||||
Then configure the URL as `http://host.docker.internal:<port>`. Also ensure the host service binds to an address reachable from containers (not only a loopback interface the Docker bridge cannot reach) and that your host firewall allows the connection.
|
||||
|
||||
### 9. "Failed to verify state directory" / restart loop on a bind-mounted state dir (#7027)
|
||||
|
||||
**Symptom**: Single-container deploy with the state directory bind-mounted from a
|
||||
host directory, no `WANTED_UID` set. The container exits 1 and restart-loops:
|
||||
|
||||
```
|
||||
-- Auto-detected workspace UID: 1024 (from /workspace)
|
||||
touch: cannot touch '/app/data/.testfile': Permission denied
|
||||
!! ERROR: Failed to verify state directory at /app/data
|
||||
```
|
||||
|
||||
**Cause**: UID auto-detection used to read `/workspace` before the configured
|
||||
state directory. In a stock image `/workspace` exists and is owned by the
|
||||
image's own build-time `1024:1024`, so detection returned a value that carries
|
||||
no information about the host — and because `1024` is also the fallback default,
|
||||
the log read as if detection had found nothing.
|
||||
|
||||
**Fix**: Fixed in the init script — the configured `HERMES_WEBUI_STATE_DIR` is
|
||||
now probed first. The full order is:
|
||||
|
||||
1. `$HERMES_WEBUI_STATE_DIR` (default `/app/data`) — a bind mount by definition
|
||||
in a single-container deploy, so its owner is the host identity to match
|
||||
2. `/home/hermeswebui/.hermes`, `$HERMES_HOME`, `/opt/data` — the hermes-home
|
||||
shared volume in two-container setups (#668)
|
||||
3. `/workspace` — used only when nothing above resolves
|
||||
4. `1024` — fallback default
|
||||
|
||||
Root-owned candidates (UID 0, e.g. a freshly created named volume) are skipped
|
||||
at every step. An explicitly supplied `WANTED_UID`/`WANTED_GID` always wins and
|
||||
is never overwritten by detection — including the value `1024`, which earlier
|
||||
versions treated as "unset".
|
||||
|
||||
If you are on an older image, the workaround is to set the IDs explicitly:
|
||||
|
||||
```bash
|
||||
docker run -e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) ...
|
||||
```
|
||||
|
||||
## Multi-container architecture
|
||||
|
||||
The two- and three-container setups use **named Docker volumes** (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required.
|
||||
@@ -572,6 +626,7 @@ volumes:
|
||||
- #681 — tools running in WebUI container, not agent container (architectural)
|
||||
- #668 — auto-detect UID/GID from mounted volume
|
||||
- #569 — UID/GID detection priority order
|
||||
- #7027 — state dir probed before `/workspace` in UID/GID detection (see [#9 above](#9-failed-to-verify-state-directory--restart-loop-on-a-bind-mounted-state-dir-7027))
|
||||
|
||||
If you hit a new failure mode not covered here, please [open an issue](https://github.com/nesquena/hermes-webui/issues/new) with:
|
||||
|
||||
|
||||
+39
-5
@@ -34,19 +34,53 @@ The Hermes Web UI is fully responsive with a mobile-optimized layout
|
||||
(hamburger sidebar, sidebar top tabs in the drawer, touch-friendly controls),
|
||||
so it works well as a daily-driver agent interface from your phone.
|
||||
|
||||
**Setup:**
|
||||
**Preferred setup: Tailscale Serve**
|
||||
|
||||
1. Install [Tailscale](https://tailscale.com/download) on your server and
|
||||
your iPhone/Android.
|
||||
2. Start the WebUI listening on all interfaces with password auth enabled:
|
||||
2. Keep the WebUI bound to localhost and enable password auth:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_PASSWORD=your-secret ./start.sh
|
||||
```
|
||||
|
||||
3. Publish the local WebUI port through Tailscale Serve:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg 8787
|
||||
```
|
||||
|
||||
4. Open the HTTPS MagicDNS URL that Tailscale prints in your phone's browser.
|
||||
|
||||
Tailscale Serve keeps WebUI on loopback while giving your tailnet an HTTPS
|
||||
MagicDNS hostname. On Linux, changing Serve configuration may require elevated
|
||||
permissions. If `tailscale serve --bg 8787` reports `Access denied: serve
|
||||
config denied`, either run it with sudo:
|
||||
|
||||
```bash
|
||||
sudo -S -p '' tailscale serve --bg 8787
|
||||
```
|
||||
|
||||
Or allow the supervised non-root WebUI/Hermes user to manage Tailscale:
|
||||
|
||||
```bash
|
||||
sudo -S -p '' tailscale set --operator=$USER
|
||||
tailscale serve --bg 8787
|
||||
```
|
||||
|
||||
**Fallback: direct tailnet IP**
|
||||
|
||||
Use direct tailnet access when Tailscale Serve is unavailable, disabled, or not
|
||||
permitted. Because this binds WebUI beyond loopback, always enable password
|
||||
auth:
|
||||
|
||||
```bash
|
||||
HERMES_WEBUI_HOST=0.0.0.0 HERMES_WEBUI_PASSWORD=your-secret ./start.sh
|
||||
```
|
||||
|
||||
3. Open `http://<server-tailscale-ip>:8787` in your phone's browser
|
||||
(find your server's Tailscale IP in the Tailscale app or with
|
||||
`tailscale ip -4` on the server).
|
||||
Then open `http://<server-tailscale-ip>:8787` in your phone's browser (find
|
||||
your server's Tailscale IP in the Tailscale app or with `tailscale ip -4` on
|
||||
the server).
|
||||
|
||||
That's it. Traffic is encrypted end-to-end by WireGuard, and password auth
|
||||
protects the UI at the application level. You can add it to your home screen
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Remote Terminal Workspaces
|
||||
|
||||
Architecture contract and path-resolution semantics for remote terminal profiles (SSH, Docker) in Hermes WebUI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
When a Hermes profile is configured with a remote terminal backend (e.g. `terminal.backend: "ssh"` or `"docker"`), its working directory (`terminal.cwd`) lives on the remote target host rather than the local WebUI host filesystem.
|
||||
|
||||
On hosts such as macOS, local path resolution via `Path.resolve()` or `os.path.realpath()` expands synthetic firmlinks (e.g. rewriting `/home/<user>` to `/System/Volumes/Data/home/<user>`). Because the target-side path does not exist on the local macOS server, unconstrained local resolution causes runtime validation failures and session corruption.
|
||||
|
||||
---
|
||||
|
||||
## 2. Path Resolution Contract
|
||||
|
||||
1. **Target-side POSIX Path Preservation:**
|
||||
- Any valid POSIX path at or beneath a remote profile's configured `terminal.cwd` is preserved verbatim as a `Path` object without calling host-local `Path.resolve()`.
|
||||
- Traversal escapes (`..`), null bytes (`\0`), and blocked system roots (`/etc`, `/usr`, `/var`, `/sys`, `/proc`, `/dev`) continue to be strictly rejected.
|
||||
|
||||
2. **Profile Isolation & Boundary Enforcement:**
|
||||
- Remote path recognition is **strictly scoped to the target/active profile**.
|
||||
- If an active profile has a local backend, target-side remote paths belonging to inactive named profiles are **not** treated as remote for the active local profile.
|
||||
- For local profiles, workspace addition (`/api/workspaces/add`) enforces host-local directory existence and permissions.
|
||||
- For remote profiles, workspace addition validates against the profile's remote `terminal.cwd` and skips host-local directory creation (`mkdir`).
|
||||
|
||||
3. **Session & Streaming Lifecycle:**
|
||||
- `Session.__init__` and `Session.load` normalize session workspace paths through `_resolve_path(..., profile=profile)`, preserving target-side paths for remote profiles and preventing corruption of `session.workspace` or `session.created_workspace`.
|
||||
- Streaming execution (`_run_agent_streaming`) and multimodal asset root resolution preserve the remote session workspace when updating runtime run state.
|
||||
@@ -142,6 +142,11 @@ gate rather than inventing values without source support.
|
||||
|
||||
## Event taxonomy (Phase 1 draft)
|
||||
|
||||
> **Semantic names below are aspirational for the per-session endpoint.** Live
|
||||
> `/api/chat/stream` wire names are listed in **Authoritative emitted events**
|
||||
> immediately after this table — use those when writing clients against current
|
||||
> source.
|
||||
|
||||
| event_type | Source | Description |
|
||||
|---|---|---|
|
||||
| `chat_delta` | run journal / live stream | Token or chunk from an assistant reply. |
|
||||
@@ -154,8 +159,45 @@ gate rather than inventing values without source support.
|
||||
| `session_snapshot` | server fallback | Current session projection; emitted when replay is unavailable. |
|
||||
| `heartbeat` | server | Keepalive emitted on the `_SSE_HEARTBEAT_INTERVAL_SECONDS` cadence. |
|
||||
|
||||
The event-type table is a draft. The final table must be confirmed during
|
||||
maintainer review before implementation.
|
||||
## Authoritative emitted events (`/api/chat/stream`)
|
||||
|
||||
These are the **real wire `event:` names** emitted by `api/streaming.py` today
|
||||
(23 names). Clients and docs must use this table — not the semantic draft above —
|
||||
when integrating with the live chat SSE relay.
|
||||
|
||||
| Wire name | Role |
|
||||
|---|---|
|
||||
| `token` | Assistant text delta |
|
||||
| `reasoning` | Model reasoning / thinking delta |
|
||||
| `tool` | Tool call started |
|
||||
| `tool_complete` | Tool call finished (result or error) |
|
||||
| `interim_assistant` | Mid-turn assistant prose (pre-final) |
|
||||
| `approval` | Destructive-command approval prompt |
|
||||
| `clarify` | Structured clarification prompt |
|
||||
| `compressing` | Context compression started |
|
||||
| `compressed` | Context compression finished |
|
||||
| `title` | Session title update (often after `done`) |
|
||||
| `title_status` | Title generation status / skip reason |
|
||||
| `warning` | Non-fatal provider/fallback warning |
|
||||
| `apperror` | Terminal application error (no trailing `stream_end`) |
|
||||
| `cancel` | Run cancelled |
|
||||
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
|
||||
| `stream_end` | SSE fence — close the client EventSource |
|
||||
| `metering` | Token/cost metering snapshot |
|
||||
| `context_status` | Context window / usage status |
|
||||
| `goal` | Goal / plan card update |
|
||||
| `goal_continue` | Goal continuation signal |
|
||||
| `pending_steer_leftover` | Leftover steer text after interrupt |
|
||||
| `state_saved` | Durable state write acknowledgment |
|
||||
| `todo_state` | Todo / checklist panel update |
|
||||
|
||||
Relay close set (stop draining the live queue): `stream_end`, `cancel`,
|
||||
`apperror`, and legacy `error` — see `api.run_journal.SSE_RELAY_CLOSE_EVENTS`.
|
||||
`done` is **not** a relay-close event because `title` and `stream_end` follow it.
|
||||
|
||||
The semantic taxonomy table remains a draft for the proposed per-session
|
||||
endpoint vocabulary and must be confirmed during maintainer review before that
|
||||
endpoint claims parity.
|
||||
|
||||
## Cursor and resume semantics
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- **Status:** Proposed
|
||||
- **Author:** @franksong2702
|
||||
- **Created:** 2026-05-16
|
||||
- **Updated:** 2026-07-16
|
||||
- **Updated:** 2026-08-22
|
||||
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
|
||||
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`hermes-run-adapter-contract.md`](hermes-run-adapter-contract.md), [`stable-assistant-turn-anchors.md`](stable-assistant-turn-anchors.md)
|
||||
|
||||
@@ -70,6 +70,7 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
| Model context / `context_messages` | Supplies conversation state to the agent | Must include the current visible user turn unless deliberately excluded with a user-visible reason | Let the agent resume from context that contradicts what the user can see |
|
||||
| Pending turn metadata | Bridges submitted-but-not-yet-finalized user input | Must identify the user turn and stream that own active work | Become a permanent duplicate transcript row after recovery |
|
||||
| Live stream / SSE | Delivers active runtime events to the browser | Must remain an observation path, not the only durable truth for already-emitted events | Lose the visible scene on refresh, reconnect, or session switch |
|
||||
| Worker lifecycle registry (`ACTIVE_RUNS`) | Tracks whether a worker still occupies the session, so a successor turn cannot start on top of it | Broader than "attachable UI work": a cancelled worker stays registered while it unwinds | Be read directly as the set of runs a browser may attach to |
|
||||
| Run journal / replay | Rebuilds emitted runtime events after reconnect or restart | Must be cursor-safe and idempotent | Duplicate assistant text, thinking text, tool cards, or compression cards |
|
||||
| Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent |
|
||||
| Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists |
|
||||
@@ -98,6 +99,16 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
browser-facing timeline renderer as live SSE events so recovery does not
|
||||
downgrade a structured Thinking / progress / tool / compression turn into a
|
||||
separate flattened presentation.
|
||||
When session loading combines a WebUI sidecar with Hermes Agent `state.db`, a
|
||||
native-image user turn may appear as both rich multipart content and scalar
|
||||
text that replaces each image part with `[screenshot]`. Reconciliation may
|
||||
treat those rows as one turn only when the multipart value contains text and
|
||||
recognized native-image parts, its exact scalar projection matches, role and
|
||||
tool shape match, timestamps match exactly, stable IDs and provider metadata
|
||||
do not conflict, and the pairing is unambiguous. Keep the rich sidecar row;
|
||||
if any requirement is missing or contradictory, preserve both rows rather
|
||||
than deduplicating. Literal scalar `[screenshot]` text alone is not identity
|
||||
evidence.
|
||||
Visible interim assistant progress must remain visible timeline content; a
|
||||
compact Activity disclosure may summarize adjacent tool/debug detail, but it
|
||||
must not be the only place where the user can see emitted progress text.
|
||||
@@ -121,6 +132,26 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
8. **Every mutation names its layer.** A PR touching streaming, recovery,
|
||||
context reconstruction, compression, replay, or sidebar metadata should state
|
||||
which layer it changes and what regression proves the invariant still holds.
|
||||
9. **Lifecycle-busy is not client-attachable.** `ACTIVE_RUNS` answers "may a new
|
||||
turn start?", not "may a browser attach a renderer?". Cancellation splits the
|
||||
two: `cancel_stream()` keeps the row as `phase="cancelling"` so a successor
|
||||
cannot overlap the unwinding worker, but the client has already reached a
|
||||
terminal state for that stream because its run journal ends in a terminal
|
||||
event. Recovery paths that hand a stream id to a renderer — session SSE
|
||||
recovery and hidden-tab status polling — must therefore exclude cancelling
|
||||
rows, while busy/admission checks must keep counting them. Reading the
|
||||
registry with a single meaning resurrects a cancelled run on every fresh
|
||||
subscription: the client attaches, consumes the terminal event, tears the
|
||||
renderer down, resubscribes, and the loop repeats indefinitely.
|
||||
|
||||
Because a cancelling row can otherwise persist forever, cancellation unwind is
|
||||
bounded: a cancelling row older than that window **and** owning no live
|
||||
`STREAMS` channel is reclaimed from `ACTIVE_RUNS` along with its stream-owner
|
||||
entry, so a wedged worker cannot suppress background wakeups permanently.
|
||||
Reclamation requires both conditions — age alone must not evict a row that
|
||||
still owns a live channel. Staleness is measured from the cancellation
|
||||
timestamp (falling back to run start), so a long-running turn cancelled
|
||||
moments ago is never mistaken for an orphan.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
@@ -138,6 +169,11 @@ context reconstruction, or session metadata:
|
||||
interim assistant text, tool cards, compression cards, and terminal states?
|
||||
- Can this change move a session in the sidebar without meaningful user or
|
||||
assistant activity?
|
||||
- Does this change read `ACTIVE_RUNS` for admission ("may a turn start?") or for
|
||||
attachment ("may a browser render this?"), and does it use the matching
|
||||
predicate for that question?
|
||||
- If it introduces or changes a reclamation window, what proves an in-flight
|
||||
cancellation is not evicted early, and that a wedged one is eventually freed?
|
||||
- Can automatic compression or recovery text become visible active-turn content?
|
||||
- What test or manual evidence proves the invariant?
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# SSE streams and capability signaling
|
||||
|
||||
Cross-client reference for the server-sent events (SSE) endpoints Hermes WebUI
|
||||
exposes. Browser and non-browser clients (Android wrapper, CLI observers)
|
||||
should integrate against this page so every client describes the same
|
||||
behavior.
|
||||
|
||||
All endpoints below are served by the WebUI origin and sit behind the same
|
||||
authentication as every other `/api/*` route: when a WebUI password or OIDC
|
||||
is configured, clients must authenticate before opening any stream.
|
||||
|
||||
## Endpoint inventory
|
||||
|
||||
| Endpoint | Availability | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /api/chat/stream?stream_id=<id>` | Always on | Live agent-turn relay (tokens, tool calls, approvals, `done`, `stream_end`). Falls back to run-journal replay when the in-memory stream is gone. Resume cursors: `after_event_id` / `after_seq` query params, with the standard `Last-Event-ID` header as fallback (events carry `id: <stream_id>:<seq>`). An invalid/foreign/ahead-of-stream cursor is honored as replay-from-start rather than silently skipping events. |
|
||||
| `GET /api/session/stream?session_id=<id>` | Always on | Persistent per-session channel that survives across agent turns (`initial`, `server_turn_started`, `session-updated`, `bg_task_complete`). This is the stream the WebUI frontend keeps open per session and the stream non-browser clients should prefer for background session updates. |
|
||||
| `GET /api/sessions/events` | Always on | Global session-list invalidation (`sessions_changed` + keepalives). A signal to re-read `/api/sessions`, not a per-session lifecycle feed. |
|
||||
| `GET /api/sessions/{session_id}/events` | Always on | Per-session run-journal relay with `Last-Event-ID` / `after_event_id` resume and snapshot fallback. See `docs/rfcs/session-sse-contract-v1.md` for the contract and its proof gates. |
|
||||
| `GET /api/sessions/gateway/stream` | Optional | Real-time updates for CLI/TUI/messaging (agent) sessions merged into the sidebar. Only streams when the **Agent sessions** setting (`show_cli_sessions`) is enabled and the gateway watcher thread is running. |
|
||||
|
||||
The authoritative `event:` names on `/api/chat/stream` are listed in the
|
||||
**Authoritative emitted events** table of
|
||||
[`docs/rfcs/session-sse-contract-v1.md`](rfcs/session-sse-contract-v1.md).
|
||||
|
||||
## Gateway probe scope (important for non-browser clients)
|
||||
|
||||
`GET /api/sessions/gateway/stream?probe=1` returns a JSON capability payload
|
||||
for the **optional gateway stream only** instead of holding an SSE connection:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"ok": false,
|
||||
"watcher_running": false,
|
||||
"fallback_poll_ms": 30000,
|
||||
"error": "agent sessions not enabled",
|
||||
"scope": "gateway_sessions",
|
||||
"session_stream_available": true,
|
||||
"session_stream_path": "/api/session/stream"
|
||||
}
|
||||
```
|
||||
|
||||
- `404` + `error: "agent sessions not enabled"` means only that the optional
|
||||
gateway/agent-sessions stream is disabled on this server.
|
||||
- `503` + `error: "watcher not started"` means the setting is on but the
|
||||
gateway watcher thread is not running.
|
||||
- `200` with `ok: true` means gateway SSE is usable.
|
||||
|
||||
**A negative gateway probe result must not be treated as "SSE unavailable".**
|
||||
The persistent per-session stream (`/api/session/stream`) and the chat-turn
|
||||
relay (`/api/chat/stream`) are always on and are not gated by the Agent
|
||||
sessions setting. The `scope`, `session_stream_available`, and
|
||||
`session_stream_path` fields make this explicit so clients do not need to
|
||||
infer it from the status code. Clients that only need session updates should
|
||||
use `/api/session/stream` directly; the gateway probe is only relevant for
|
||||
clients that display CLI/TUI/messaging sessions.
|
||||
|
||||
## Heartbeats and proxy behavior
|
||||
|
||||
- All long-lived streams emit SSE keepalive comment lines on the
|
||||
`_SSE_HEARTBEAT_INTERVAL_SECONDS` cadence (currently 5 seconds), which is
|
||||
short enough to survive typical reverse-proxy idle timeouts.
|
||||
- Handlers send `X-Accel-Buffering: no` so nginx-style proxies pass events
|
||||
through unbuffered.
|
||||
- Deployments behind buffering proxies that read-until-close (notably
|
||||
Tornado-based `jupyter-server-proxy`) can set `HERMES_WEBUI_SSE_CHUNKED=1`
|
||||
to frame each event as an HTTP/1.1 chunk. The default wire format is
|
||||
unchanged when the flag is unset.
|
||||
+18
-6
@@ -179,13 +179,23 @@ turn in the exhausted session instead of being blocked with recovery guidance.
|
||||
|
||||
## "Hermes Agent was updated while Hermes WebUI was running"
|
||||
|
||||
**Symptom.** An action that uses the in-process Agent runtime stops with a message telling you to restart Hermes WebUI. This can happen after `hermes update`, a Git checkout/pull in the Agent source tree, or another tool updates Hermes Agent without restarting the already-running WebUI backend.
|
||||
**Symptom.** An action that uses the in-process Agent runtime stops with a message telling you to restart Hermes WebUI manually. This can happen after `hermes update`, a Git checkout/pull in the Agent source tree, or another tool updates Hermes Agent without restarting the already-running WebUI backend.
|
||||
|
||||
**Why.** WebUI currently imports `run_agent.AIAgent` into its long-lived Python process. Python keeps imported modules in memory. Continuing after a known Agent Git revision changes could combine cached modules from the old revision with source read from the new revision, producing misleading `ImportError`s or inconsistent runtime state. For local Agent-backed chat, WebUI therefore returns a retryable `409 agent_runtime_stale` before claiming or mutating session state instead of attempting a partial in-process reload. Gateway-backed chat runs in the gateway process and is not blocked by this WebUI-local check. Non-Git Agent installs preserve their existing behavior because there is no revision identity to compare.
|
||||
**Why.** WebUI imports `run_agent.AIAgent` into its long-lived Python process. Continuing after a known Agent Git revision changes could combine cached modules from the old revision with source read from the new revision. Local Agent-backed actions return a retryable `409 agent_runtime_stale` with `restart_scheduled: false` before accepting a new turn. Gateway- and runner-owned chat keep their existing runtime ownership. Non-Git Agent installs preserve their existing behavior because there is no revision identity to compare; losing a previously known revision remains fail-closed.
|
||||
|
||||
**Diagnostic.** Compare the running WebUI process start time with the Agent checkout revision and recent update history. If the Agent was updated after WebUI started, restart WebUI before investigating individual missing-symbol errors.
|
||||
**Diagnostic.** The stale-runtime response includes `agent_update_state`, also preserved in asynchronous compression error status:
|
||||
|
||||
**Fix.** Restart using the same launch method that started WebUI:
|
||||
| Value | Observation |
|
||||
| --- | --- |
|
||||
| `active` | A recent Agent update marker names a live PID. |
|
||||
| `incomplete` | An Agent recovery marker exists in the loaded checkout or configured venv installation. |
|
||||
| `stale` | The update marker names a dead PID or is older than the diagnostic age limit. |
|
||||
| `unknown` | Marker contents, PID liveness, or recovery-marker presence cannot be read or classified. |
|
||||
| `unverified` | No active or recovery marker was found. Update completion and environment health remain unverified. |
|
||||
|
||||
These are observations, not success receipts. Hermes Agent removes `.hermes-update-in-progress` on failed and interrupted exits too. A missing or stale marker, or a readable Git revision, does not prove a completed update or a healthy environment. WebUI only reads these markers; it does not remove or repair them.
|
||||
|
||||
**Fix.** Check the Agent updater's outcome and resolve any failed or incomplete Agent update first. Once the Agent checkout and environment are healthy and no updater is running, restart WebUI using the same launch method that started it:
|
||||
|
||||
```bash
|
||||
./ctl.sh restart
|
||||
@@ -193,9 +203,11 @@ turn in the exhausted session instead of being blocked with recovery guidance.
|
||||
systemctl --user restart hermes-webui.service
|
||||
```
|
||||
|
||||
If you launched `python3 bootstrap.py` in the foreground, stop it with Ctrl-C and start it again. Restarting the whole computer or WSL is not required when restarting the WebUI backend succeeds.
|
||||
For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again. Restarting the whole computer or WSL is not required when restarting the WebUI backend succeeds. Retry the action after restarting the backend; refreshing the browser alone does not replace its imported Agent modules.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if the restart-required message appears even though the Agent revision did not change, or if a clean WebUI restart still produces the same import error. Include the WebUI launch method, WebUI revision, Agent revision, and the sanitized error text.
|
||||
**Automatic restart prerequisite.** Revision mismatch does not schedule a WebUI restart. Safe automation requires an Agent-owned terminal success receipt bound to the exact update transaction, final revision, and healthy environment, plus an Agent-owned atomic handoff or lease that excludes new mutations across process replacement (or an Agent updater that performs the restart itself). No such public contract is verified for this integration. Repeated readiness checks followed by `os.execv()` leave a race; WebUI's own update lock does not exclude an external Agent updater. Explicit updates initiated through WebUI retain their existing behavior and are outside this revision-mismatch guard.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if the restart-required message appears even though the Agent revision did not change or become unreadable, or if a clean WebUI restart still produces the same import error. Include the launch method, WebUI and Agent revisions, the marker diagnostic, and sanitized error text.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+18
-1
@@ -7,7 +7,7 @@ Option A rewrite (2026-05-08): imports api.models and api.profiles
|
||||
directly from the webui codebase, using canonical helpers for
|
||||
locking, profile scoping, index consistency, and validation.
|
||||
|
||||
pip install mcp # one-time setup
|
||||
pip install "mcp>=1.28,<2" # one-time setup
|
||||
python3 mcp_server.py # start via stdio
|
||||
|
||||
MCP config for Hermes Agent (add to config.yaml):
|
||||
@@ -447,6 +447,23 @@ async def handle_move_session(arguments: dict) -> list[TextContent]:
|
||||
}, ensure_ascii=False, indent=2))]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# MCP SDK version guard
|
||||
# ═════════════════════════════════════════════════════════════════════════==
|
||||
#
|
||||
# mcp>=2.0.0 removed the Server.list_tools / Server.call_tool decorator API
|
||||
# used by this server. Fail fast instead of producing secondary errors.
|
||||
|
||||
if not hasattr(Server, "list_tools"): # pragma: no cover
|
||||
import sys
|
||||
print(
|
||||
"ERROR: mcp SDK incompatible. This server requires mcp>=1.28,<2 "
|
||||
"(the 1.x Python SDK with the decorator API). "
|
||||
"Run: pip install 'mcp>=1.28,<2'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# MCP Server wiring
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -21,6 +21,14 @@ dynamic = ["version", "dependencies"]
|
||||
Repository = "https://github.com/nesquena/hermes-webui"
|
||||
Issues = "https://github.com/nesquena/hermes-webui/issues"
|
||||
|
||||
[project.scripts]
|
||||
# Packaged CLI entry point (#6739): `hermes-webui` starts the WebUI server, the
|
||||
# pip-installed equivalent of `python server.py` from a source checkout. The
|
||||
# generated console script calls bootstrap.main(), which runs launcher-python
|
||||
# discovery and dep-ensure before launching the server — matching the effective
|
||||
# behavior of the `python server.py` / `python -m server` launch surface.
|
||||
hermes-webui = "bootstrap:main"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
packages = ["api", "static"]
|
||||
|
||||
@@ -8,7 +8,7 @@ pytest-timeout
|
||||
pytest-asyncio
|
||||
pytest-shard
|
||||
ruff
|
||||
mcp
|
||||
mcp>=1.28,<2
|
||||
# Optional Office parsers — runtime-optional (commented in requirements.txt), but
|
||||
# installed here so the workspace Office-preview tests (tests/test_issue540_*.py)
|
||||
# run under the documented ./scripts/test.sh entry point. These files
|
||||
|
||||
@@ -716,9 +716,9 @@ def main() -> None:
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _request_shutdown)
|
||||
signal.signal(signal.SIGINT, _request_shutdown) # Ctrl-C / ctl.sh daemons (#7078)
|
||||
except (ValueError, OSError):
|
||||
# Not on the main thread (e.g. embedded/test harness); skip handler.
|
||||
logger.debug("Could not install SIGTERM handler", exc_info=True)
|
||||
logger.debug("Could not install shutdown signal handlers", exc_info=True)
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
|
||||
@@ -302,6 +302,7 @@
|
||||
?normalizeAssistantTurnAnchorTerminalState(explicit,sourceType)||explicit
|
||||
:explicit;
|
||||
}
|
||||
if(sourceType==='interim_assistant') return 'completed'; // interim prose is a complete semantic boundary, unlike token deltas.
|
||||
if(kind==='tool_started') return 'running';
|
||||
if(kind==='tool_completed') return _own(payload,'is_error')?'error':'completed';
|
||||
if(kind==='terminal_status'){
|
||||
|
||||
+74
-7
@@ -132,6 +132,9 @@ function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){
|
||||
function _profileQueryBlocksSavedLocalRestore(profileIntent, urlSession){
|
||||
return !!(profileIntent&&profileIntent.hasParam&&profileIntent.valid&&!urlSession);
|
||||
}
|
||||
function _shouldStartFreshPwaChat(action,urlSession){
|
||||
return action==='new-chat'&&!urlSession;
|
||||
}
|
||||
async function _applyComposerPrefillOnBoot(prefillIntent){
|
||||
if(!prefillIntent||!prefillIntent.hasText) return;
|
||||
const msg=(typeof $==='function')?$('msg'):document.getElementById('msg');
|
||||
@@ -526,6 +529,12 @@ function _isSidebarCollapsed(){
|
||||
function _syncSidebarAria(){
|
||||
// Mirror the open/collapsed state on the active rail button via aria-expanded
|
||||
// so screen readers announce the toggle. Open=true, collapsed=false.
|
||||
// Clear the others first: only one panel owns the sidebar, but this used to set
|
||||
// aria-expanded on the newly-active button without unsetting the previous one,
|
||||
// so chat -> kanban -> skills left three buttons claiming aria-expanded=true
|
||||
// and a screen reader announced three open disclosures.
|
||||
document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel][aria-expanded]')
|
||||
.forEach(function(btn){ btn.setAttribute('aria-expanded','false'); });
|
||||
const active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
|
||||
if(active)active.setAttribute('aria-expanded',!_isSidebarCollapsed());
|
||||
}
|
||||
@@ -1333,6 +1342,47 @@ const _DEFAULT_MESSAGE_MODES=['queue','interrupt','steer'];
|
||||
// existing user's persisted busy-input-mode preference survives the rename.
|
||||
const _LEGACY_DEFAULT_MESSAGE_MODE_KEY='hermes-busy-input-mode';
|
||||
const _DEFAULT_MESSAGE_MODE_KEY='hermes-default-message-mode';
|
||||
// ── Auto-follow eager mirror (#6819) ────────────────────────────────────────
|
||||
// PERSISTENCE CONTRACT (client-side mirror of the Auto-follow new content
|
||||
// setting, `auto_scroll_follow`):
|
||||
//
|
||||
// * Why: the boot settings-fetch-failure path used to hardcode
|
||||
// `window._autoScrollFollow=true`, silently clobbering an explicit OFF for
|
||||
// the whole session (post-turn scroll yanks until the next refresh).
|
||||
// * Storage: ONE global localStorage value (`'1'`/`'0'`) under
|
||||
// `_AUTO_SCROLL_FOLLOW_KEY` — NOT profile-keyed. The backend authority is
|
||||
// global: `SETTINGS_FILE = STATE_DIR / "settings.json"` (api/config.py)
|
||||
// and `/api/settings` calls `load_settings()` with no profile-specific
|
||||
// file, so the mirror must match that single global contract. A
|
||||
// profile-keyed map would leave a freshly-opened profile with no entry and
|
||||
// wrongly fall back to ON despite the global OFF (maintainer review on
|
||||
// #6856).
|
||||
// * Write semantics: written ONLY when a settings response (or the boot
|
||||
// settings path) actually resolves the setting; the fallback path never
|
||||
// writes.
|
||||
// * Read semantics: `_readPersistedAutoScrollFollow()` returns the stored
|
||||
// value if present, else `true` (the config.py default). The boot-failure
|
||||
// path reads it directly (no profile resolution needed — the mirror is
|
||||
// global, so it can be read synchronously in the fallback). Fresh users
|
||||
// (no mirror) get ON.
|
||||
// * Upgrade: there is no legacy key; the mirror is created on first settings
|
||||
// resolve, so older sessions simply default to ON until the next settings
|
||||
// round-trip writes it.
|
||||
const _AUTO_SCROLL_FOLLOW_KEY='hermes-auto-scroll-follow';
|
||||
function _persistAutoScrollFollow(enabled){
|
||||
try{localStorage.setItem(_AUTO_SCROLL_FOLLOW_KEY,enabled?'1':'0');}catch(_){}
|
||||
return enabled;
|
||||
}
|
||||
function _readPersistedAutoScrollFollow(){
|
||||
try{
|
||||
const raw=localStorage.getItem(_AUTO_SCROLL_FOLLOW_KEY);
|
||||
if(raw==='1') return true;
|
||||
if(raw==='0') return false;
|
||||
}catch(_){}
|
||||
return true; // default: follow ON (matches config.py default)
|
||||
}
|
||||
window._persistAutoScrollFollow=_persistAutoScrollFollow;
|
||||
window._readPersistedAutoScrollFollow=_readPersistedAutoScrollFollow;
|
||||
function _normalizeDefaultMessageMode(mode){
|
||||
return _DEFAULT_MESSAGE_MODES.includes(mode)?mode:'steer';
|
||||
}
|
||||
@@ -2054,9 +2104,17 @@ $('btnDownload').onclick=()=>{
|
||||
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
|
||||
a.download=`hermes-${S.session.session_id}.md`;a.click();URL.revokeObjectURL(a.href);
|
||||
};
|
||||
function _buildSessionExportUrl(sessionId,params){
|
||||
const url=new URL('api/session/export',document.baseURI||location.href);
|
||||
url.searchParams.set('session_id',String(sessionId||''));
|
||||
Object.entries(params||{}).forEach(([key,value])=>{
|
||||
if(value!==undefined&&value!==null)url.searchParams.set(key,String(value));
|
||||
});
|
||||
return url.href;
|
||||
}
|
||||
$('btnExportJSON').onclick=()=>{
|
||||
if(!S.session)return;
|
||||
const url=`/api/session/export?session_id=${encodeURIComponent(S.session.session_id)}`;
|
||||
const url=_buildSessionExportUrl(S.session.session_id);
|
||||
const a=document.createElement('a');a.href=url;
|
||||
a.download=`hermes-${S.session.session_id}.json`;a.click();
|
||||
};
|
||||
@@ -2133,7 +2191,7 @@ function exportSessionHTML(session){
|
||||
// Drop empties so the inlined fallback keeps working for anything we couldn't read.
|
||||
const clean={};for(const k in palette){if(palette[k])clean[k]=palette[k];}
|
||||
const paletteB64=btoa(unescape(encodeURIComponent(JSON.stringify(clean))));
|
||||
const url=`/api/session/export?session_id=${encodeURIComponent(sid)}&format=html&theme=${theme}&palette=${encodeURIComponent(paletteB64)}`;
|
||||
const url=_buildSessionExportUrl(sid,{format:'html',theme,palette:paletteB64});
|
||||
const a=document.createElement('a');a.href=url;
|
||||
a.download=`hermes-${sid}.html`;a.click();
|
||||
}
|
||||
@@ -3283,7 +3341,11 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
window._showBusyPlaceholderHint=!!s.show_busy_placeholder_hint;
|
||||
window._newChatOnWorkspaceSwitch=!!s.new_chat_on_workspace_switch; // #5473 opt-in
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._autoScrollFollow=s.auto_scroll_follow!==false;
|
||||
// #6819: persist the resolved auto-follow value into the global mirror.
|
||||
// The mirror is NOT profile-keyed (the backend setting is one global
|
||||
// settings.json), so it can be written synchronously here — no deferred
|
||||
// write needed.
|
||||
window._autoScrollFollow=_persistAutoScrollFollow(s.auto_scroll_follow!==false);
|
||||
window._largeTextPasteAsAttachment=s.large_text_paste_as_attachment!==false;
|
||||
window._projectQuickCreate=!!s.project_quick_create_buttons;
|
||||
window._composerControlVisibility=_composerControlVisibilityFromSettings(s);
|
||||
@@ -3425,7 +3487,12 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
window._defaultMessageMode=_readPersistedDefaultMessageMode();
|
||||
window._showBusyPlaceholderHint=false;
|
||||
window._sessionEndlessScrollEnabled=false;
|
||||
window._autoScrollFollow=true;
|
||||
// #6819: honor the persisted auto-follow preference on settings-fetch
|
||||
// failure instead of hardcoding ON (which silently clobbered an explicit
|
||||
// OFF and caused post-turn scroll yanks until the next refresh). The
|
||||
// mirror is global (matches the one global settings.json), so it is read
|
||||
// synchronously here — no profile resolution or deferral needed.
|
||||
window._autoScrollFollow=_readPersistedAutoScrollFollow();
|
||||
window._composerControlVisibility=_composerControlVisibilityFromSettings(null);
|
||||
window._composerControlOrder=[];
|
||||
_applyComposerControlOrder(window._composerControlOrder);
|
||||
@@ -3454,7 +3521,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
}
|
||||
const _bootActiveProfileUnauthRedirectBudget=(()=>{
|
||||
const markerKey='hermes-webui-active-profile-bootstrap-401';
|
||||
@@ -3676,7 +3743,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
|
||||
? window.HermesPWA.launchAction()
|
||||
: null;
|
||||
if(pwaLaunchAction==='new-chat'){
|
||||
if(_shouldStartFreshPwaChat(pwaLaunchAction,urlSession)){
|
||||
try{
|
||||
await newSession(true);
|
||||
// New-chat PWA launches need the empty conversation visible immediately.
|
||||
@@ -3868,5 +3935,5 @@ async function shutdownServer() {
|
||||
|
||||
function _showServerStopped() {
|
||||
var stoppedMsg = (typeof t === 'function' ? t('settings_shutdown_stopped_message') : 'Server stopped. You can close this tab.');
|
||||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--muted);font-family:system-ui,ui-sans-serif;font-size:14px"><p>' + stoppedMsg + '</p></div>';
|
||||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:var(--muted);font-family:var(--font-ui);font-size:14px"><p>' + stoppedMsg + '</p></div>';
|
||||
}
|
||||
|
||||
+90
-14
@@ -187,6 +187,7 @@ function getMatchingCommands(prefix){
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
const seen=new Set(matches.map(c=>c.name));
|
||||
const reserved=_getReservedSlashCommandSlugs();
|
||||
const bundleSlugs=new Set(_bundleCommandCache.map(bundle=>bundle.name));
|
||||
for(const [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
matches.push({
|
||||
@@ -227,10 +228,16 @@ function getMatchingCommands(prefix){
|
||||
seen.add(bundle.name);
|
||||
}
|
||||
}
|
||||
// A same-slug bundle owns dispatch. Hold plain skills until the independent
|
||||
// bundle metadata request settles so a slow bundle response cannot briefly
|
||||
// expose a selectable, shadowed skill.
|
||||
if(!_bundleCommandCacheReady)return matches;
|
||||
for(const skill of _skillCommandCache){
|
||||
if(!skill.name.startsWith(q)||seen.has(skill.name)||reserved.has(skill.name))continue;
|
||||
const name=String(skill&&skill.name||'').toLowerCase();
|
||||
const description=String(skill&&skill.desc||'').toLowerCase();
|
||||
if((!name.includes(q)&&!description.includes(q))||seen.has(name)||reserved.has(name)||bundleSlugs.has(name))continue;
|
||||
matches.push(skill);
|
||||
seen.add(skill.name);
|
||||
seen.add(name);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -1225,12 +1232,39 @@ async function cmdGoal(args){
|
||||
if(!S.session||!S.session.session_id){showToast(t('no_active_session'));return;}
|
||||
const activeSid=S.session.session_id;
|
||||
try{
|
||||
// #6703: re-assert the explicit-pick marker on /api/goal the same way
|
||||
// /api/chat/start does. Without it the server's model resolver treats a
|
||||
// persisted cross-provider pick as stale and silently reverts the session
|
||||
// to the profile default mid-session (e.g. while /goal is running).
|
||||
const _goalModel=S.session.model||($('modelSelect')&&$('modelSelect').value)||'';
|
||||
const _goalProvider=S.session.model_provider||null;
|
||||
const _pendingPick=(typeof _readPendingSessionModel==='function')
|
||||
? _readPendingSessionModel(activeSid)
|
||||
: null;
|
||||
const _pendingPickMatch=_pendingPick
|
||||
&& _pendingPick.model===_goalModel
|
||||
&& String(_pendingPick.model_provider||'')===String(_goalProvider||'');
|
||||
const _defaultModel=(typeof window!=='undefined' && window._defaultModel)||'';
|
||||
const _activeProvider=(typeof window!=='undefined' && window._activeProvider)||null;
|
||||
const _isCrossProviderPick=_goalModel
|
||||
&& _goalProvider
|
||||
&& _defaultModel
|
||||
&& _activeProvider
|
||||
&& _goalModel !== _defaultModel
|
||||
&& String(_goalProvider||'') !== String(_activeProvider||'');
|
||||
const _explicitPick=(_pendingPickMatch||_isCrossProviderPick)||undefined;
|
||||
// Do NOT consume the pending explicit-pick marker here: a control-only
|
||||
// invocation (e.g. /goal status) skips server-side model resolution, so a
|
||||
// pre-request clear would drop the pick without using it. Consume it below,
|
||||
// only after a successful kickoff (r.stream_id), re-checking that the stored
|
||||
// marker still matches the model/provider captured for this kickoff (#6705).
|
||||
const r=await api('/api/goal',{method:'POST',body:JSON.stringify({
|
||||
session_id:activeSid,
|
||||
args:args||'',
|
||||
workspace:S.session.workspace,
|
||||
model:S.session.model||($('modelSelect')&&$('modelSelect').value)||'',
|
||||
model_provider:S.session.model_provider||null,
|
||||
model:_goalModel,
|
||||
model_provider:_goalProvider,
|
||||
explicit_model_pick:_explicitPick,
|
||||
profile:S.activeProfile||S.session.profile||'default',
|
||||
})});
|
||||
const msg = (() => {
|
||||
@@ -1250,6 +1284,19 @@ async function cmdGoal(args){
|
||||
showToast(msg.split('\n')[0],2600);
|
||||
}
|
||||
if(!r||!r.stream_id)return;
|
||||
// #6705: consume the one-shot pending explicit-pick marker only after a
|
||||
// successful kickoff. Re-read the stored marker and clear it only if it
|
||||
// still matches the model/provider captured above — a control command (no
|
||||
// stream_id) must leave the marker intact for the next real send, and a
|
||||
// marker re-recorded mid-flight (newer onchange) must not be clobbered.
|
||||
if(_pendingPickMatch && typeof _readPendingSessionModel==='function' && typeof _clearPendingSessionModel==='function'){
|
||||
const _stillPending=_readPendingSessionModel(activeSid);
|
||||
if(_stillPending
|
||||
&& _stillPending.model===_goalModel
|
||||
&& String(_stillPending.model_provider||'')===String(_goalProvider||'')){
|
||||
_clearPendingSessionModel(activeSid);
|
||||
}
|
||||
}
|
||||
S.toolCalls=[];
|
||||
if(typeof clearLiveToolCards==='function')clearLiveToolCards();
|
||||
appendThinking();setBusy(true);
|
||||
@@ -1894,22 +1941,50 @@ function cmdVoice(){
|
||||
async function cmdYolo(){
|
||||
const sid=S.session&&S.session.session_id;
|
||||
if(!sid){showToast(t('yolo_no_session'));return;}
|
||||
const generation=_loadSessionGeneration;
|
||||
const viewIsCurrent=()=>!!(
|
||||
S.session&&S.session.session_id===sid&&_loadSessionGeneration===generation
|
||||
);
|
||||
let approvalOwner=null;
|
||||
try{
|
||||
// Check current state first to toggle
|
||||
// Check current state first to toggle.
|
||||
const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid));
|
||||
if(!viewIsCurrent())return;
|
||||
const enable=!status.yolo_enabled;
|
||||
await api('/api/session/yolo',{
|
||||
// A visible approval must belong to this exact session load before any
|
||||
// command handler may POST through it. Otherwise fail closed.
|
||||
const card=$('approvalCard');
|
||||
if(card&&card.classList.contains('visible')){
|
||||
approvalOwner=typeof _captureApprovalResponseOwner==='function'
|
||||
?_captureApprovalResponseOwner()
|
||||
:null;
|
||||
if(!approvalOwner)return;
|
||||
if(enable&&typeof toggleYoloFromApproval==='function'){
|
||||
await toggleYoloFromApproval();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result=await api('/api/session/yolo',{
|
||||
method:'POST',
|
||||
body:JSON.stringify({session_id:sid,enabled:enable}),
|
||||
});
|
||||
_yoloEnabled=enable;
|
||||
if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return;
|
||||
const settled=(result&&typeof result.yolo_enabled==='boolean')?result.yolo_enabled:enable;
|
||||
_yoloEnabled=settled;
|
||||
_updateYoloPill();
|
||||
showToast(enable?t('yolo_enabled'):t('yolo_disabled'));
|
||||
if(enable){
|
||||
// Dismiss any visible approval card
|
||||
hideApprovalCard(true);
|
||||
showToast(settled?t('yolo_enabled'):t('yolo_disabled'));
|
||||
}catch(e){
|
||||
if(!viewIsCurrent()||(approvalOwner&&!_approvalResponseOwnerIsCurrent(approvalOwner)))return;
|
||||
let errorPayload=null;
|
||||
if(e&&typeof e.body==='string'){
|
||||
try{errorPayload=JSON.parse(e.body);}catch(_){}
|
||||
}
|
||||
}catch(e){showToast('YOLO: '+e.message);}
|
||||
if(errorPayload&&typeof errorPayload.yolo_enabled==='boolean'){
|
||||
_yoloEnabled=errorPayload.yolo_enabled;
|
||||
_updateYoloPill();
|
||||
}
|
||||
showToast('YOLO: '+((errorPayload&&(errorPayload.error||errorPayload.message))||e.message));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Branch / fork command ──
|
||||
@@ -2086,8 +2161,9 @@ function refreshSlashCommandDropdown(){
|
||||
});
|
||||
}
|
||||
function ensureSkillCommandsLoadedForAutocomplete(){
|
||||
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
if(!_skillCommandCacheReady&&!_skillCommandLoadPromise){
|
||||
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
if(!_bundleCommandCacheReady&&!_bundleCommandLoadPromise){
|
||||
loadBundleCommands().then(()=>{refreshSlashCommandDropdown();});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,19 @@
|
||||
const SETTINGS_PREFIX='hermes.ext.settings.';
|
||||
const STORAGE_PREFIX='hermes.ext.storage.';
|
||||
const FIELD_TYPES=new Set(['boolean','string','number','integer','enum']);
|
||||
const TURN_LIFECYCLE_TYPES=new Set(['turn:start','turn:complete','turn:error','turn:cancel']);
|
||||
const TURN_LIFECYCLE_STATE_LIMIT=512;
|
||||
const schemas=new Map();
|
||||
const trustedExtensions=new Map();
|
||||
const registrations=new Map();
|
||||
const turnLifecycleListeners=new Map();
|
||||
const turnLifecycleStates=new Map();
|
||||
const configureRegistrations=new Map();
|
||||
const configureQuarantinedIds=new Set();
|
||||
const configureChangeListeners=new Set();
|
||||
const currentExtensionStatus=new Map();
|
||||
let trustedSeeded=false;
|
||||
let extensionStatusSeeded=false;
|
||||
|
||||
function extensionId(value){
|
||||
return String(value||'').trim();
|
||||
@@ -111,6 +121,7 @@
|
||||
name:text(entry&&entry.name,id),
|
||||
storage_owned:storageOwned,
|
||||
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
|
||||
effective_enabled:!(entry&&entry.effective_enabled===false),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
@@ -130,6 +141,20 @@
|
||||
}
|
||||
trustedSeeded=true;
|
||||
}
|
||||
const previousStatus=new Map(currentExtensionStatus);
|
||||
const nextIds=new Set(entries.map(entry=>entry.id));
|
||||
if(extensionStatusSeeded){
|
||||
for(const id of previousStatus.keys()){
|
||||
if(nextIds.has(id)) continue;
|
||||
configureQuarantinedIds.add(id);
|
||||
notifyConfigureChange(id,'quarantine');
|
||||
}
|
||||
}
|
||||
currentExtensionStatus.clear();
|
||||
for(const entry of entries){
|
||||
currentExtensionStatus.set(entry.id,{effective_enabled:entry.effective_enabled===true});
|
||||
}
|
||||
extensionStatusSeeded=true;
|
||||
schemas.clear();
|
||||
for(const entry of entries){
|
||||
const trusted=trustedExtensions.get(entry.id);
|
||||
@@ -141,6 +166,14 @@
|
||||
settings_schema:trusted.storage_owned===true?trusted.settings_schema:[],
|
||||
});
|
||||
}
|
||||
for(const [id,record] of configureRegistrations){
|
||||
if(!record||!record.active) continue;
|
||||
const before=previousStatus.get(id);
|
||||
const after=currentExtensionStatus.get(id);
|
||||
if(!before||!after||before.effective_enabled!==after.effective_enabled){
|
||||
notifyConfigureChange(id,'status');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadState(key){
|
||||
@@ -228,9 +261,7 @@
|
||||
return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length);
|
||||
}
|
||||
|
||||
function settingsForExtension(id){
|
||||
const clean=extensionId(id);
|
||||
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
|
||||
function settingsAccessor(clean,meta,isTrusted,allowConfigureRegistration){
|
||||
const schema=supportsSettings(meta)?meta.settings_schema:[];
|
||||
const key=settingsKey(clean);
|
||||
function current(){
|
||||
@@ -246,9 +277,9 @@
|
||||
const saved=safeWrite(key,overridesFromValues(schema,checked.values));
|
||||
return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}};
|
||||
}
|
||||
return {
|
||||
const accessor={
|
||||
extensionId:clean,
|
||||
trusted:schemas.has(clean),
|
||||
trusted:isTrusted,
|
||||
storageOwned:!!meta.storage_owned,
|
||||
supported:supportsSettings(meta),
|
||||
schema,
|
||||
@@ -275,11 +306,19 @@
|
||||
return true;
|
||||
},
|
||||
};
|
||||
if(allowConfigureRegistration===true){
|
||||
accessor.registerConfigure=handler=>registerConfigureHandler(clean,handler);
|
||||
}
|
||||
return accessor;
|
||||
}
|
||||
|
||||
function storageForExtension(id){
|
||||
function settingsForExtension(id){
|
||||
const clean=extensionId(id);
|
||||
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
|
||||
return settingsAccessor(clean,meta,schemas.has(clean));
|
||||
}
|
||||
|
||||
function storageAccessor(clean,meta){
|
||||
const allowed=!!meta.storage_owned;
|
||||
const key=storageKey(clean);
|
||||
return {
|
||||
@@ -309,12 +348,283 @@
|
||||
};
|
||||
}
|
||||
|
||||
function storageForExtension(id){
|
||||
const clean=extensionId(id);
|
||||
const meta=schemas.get(clean)||{id:clean,name:clean,storage_owned:false,settings_schema:[]};
|
||||
return storageAccessor(clean,meta);
|
||||
}
|
||||
|
||||
function eventAccessor(clean){
|
||||
return Object.freeze({
|
||||
on(type,handler){
|
||||
if(!TURN_LIFECYCLE_TYPES.has(type)||typeof handler!=='function') return null;
|
||||
let listenersByExtension=turnLifecycleListeners.get(type);
|
||||
if(!listenersByExtension){
|
||||
listenersByExtension=new Map();
|
||||
turnLifecycleListeners.set(type,listenersByExtension);
|
||||
}
|
||||
let listeners=listenersByExtension.get(clean);
|
||||
if(!listeners){
|
||||
listeners=new Set();
|
||||
listenersByExtension.set(clean,listeners);
|
||||
}
|
||||
listeners.add(handler);
|
||||
let active=true;
|
||||
return function unsubscribe(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
listeners.delete(handler);
|
||||
if(!listeners.size) listenersByExtension.delete(clean);
|
||||
if(!listenersByExtension.size) turnLifecycleListeners.delete(type);
|
||||
return true;
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function notifyConfigureChange(id,reason){
|
||||
const change=Object.freeze({id,reason});
|
||||
for(const listener of [...configureChangeListeners]){
|
||||
try{
|
||||
listener(change);
|
||||
}catch(error){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error('[Hermes extensions] Configure change listener failed:',error);}catch(_loggingError){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onConfigureChange(listener){
|
||||
if(typeof listener!=='function') return null;
|
||||
configureChangeListeners.add(listener);
|
||||
let active=true;
|
||||
return function unsubscribe(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
configureChangeListeners.delete(listener);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function registerConfigureHandler(clean,handler){
|
||||
if(typeof handler!=='function'||!trustedExtensions.has(clean)||configureQuarantinedIds.has(clean)) return null;
|
||||
if(configureRegistrations.has(clean)) return null;
|
||||
const record={handler,pending:false,active:true};
|
||||
configureRegistrations.set(clean,record);
|
||||
notifyConfigureChange(clean,'registration');
|
||||
let active=true;
|
||||
return function unregister(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
record.active=false;
|
||||
if(configureRegistrations.get(clean)===record) configureRegistrations.delete(clean);
|
||||
notifyConfigureChange(clean,'registration');
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function configureStateForExtension(id){
|
||||
const clean=extensionId(id);
|
||||
const record=configureRegistrations.get(clean);
|
||||
const status=currentExtensionStatus.get(clean);
|
||||
const available=!!(
|
||||
clean&&record&&record.active&&!configureQuarantinedIds.has(clean)
|
||||
&&status&&status.effective_enabled===true
|
||||
);
|
||||
return {available,pending:available&&record.pending===true};
|
||||
}
|
||||
|
||||
function focusableConfigureTarget(node){
|
||||
if(!node||typeof node.focus!=='function'||node.isConnected===false||node.hidden===true||node.disabled===true) return false;
|
||||
if(typeof node.getAttribute==='function'&&node.getAttribute('aria-disabled')==='true') return false;
|
||||
if(typeof node.closest==='function'&&node.closest('[hidden]')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusConfigureTarget(clean,opener){
|
||||
const candidates=[];
|
||||
if(focusableConfigureTarget(opener)) candidates.push(opener);
|
||||
if(typeof document!=='undefined'&&document){
|
||||
if(typeof document.querySelectorAll==='function'){
|
||||
const buttons=document.querySelectorAll('[data-extension-configure-id]');
|
||||
for(const button of buttons){
|
||||
if(button&&button.dataset&&button.dataset.extensionConfigureId===clean&&focusableConfigureTarget(button)){
|
||||
candidates.push(button);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(typeof document.querySelector==='function'){
|
||||
const installedTab=document.querySelector('[data-extensions-tab="installed"]');
|
||||
if(focusableConfigureTarget(installedTab)) candidates.push(installedTab);
|
||||
}
|
||||
}
|
||||
for(const candidate of candidates){
|
||||
try{
|
||||
candidate.focus({preventScroll:true});
|
||||
return true;
|
||||
}catch(_focusOptionsError){
|
||||
try{
|
||||
candidate.focus();
|
||||
return true;
|
||||
}catch(_focusError){}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function reportConfigureFailure(clean,error,onError){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error(`[Hermes extensions] ${clean} Configure handler failed:`,error);}catch(_loggingError){}
|
||||
}
|
||||
if(typeof onError==='function'){
|
||||
try{onError(error);}catch(callbackError){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error(`[Hermes extensions] ${clean} Configure failure reporter failed:`,callbackError);}catch(_loggingError){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invokeConfigure(id,options){
|
||||
const clean=extensionId(id);
|
||||
const state=configureStateForExtension(clean);
|
||||
const record=configureRegistrations.get(clean);
|
||||
if(!state.available||state.pending||!record) return false;
|
||||
const opener=options&&options.opener;
|
||||
const onError=options&&options.onError;
|
||||
let settled=false;
|
||||
let failureReported=false;
|
||||
record.pending=true;
|
||||
notifyConfigureChange(clean,'pending');
|
||||
|
||||
function settle(){
|
||||
if(settled) return false;
|
||||
settled=true;
|
||||
record.pending=false;
|
||||
notifyConfigureChange(clean,'pending');
|
||||
focusConfigureTarget(clean,opener);
|
||||
return true;
|
||||
}
|
||||
|
||||
function fail(error){
|
||||
if(!failureReported){
|
||||
failureReported=true;
|
||||
reportConfigureFailure(clean,error,onError);
|
||||
}
|
||||
settle();
|
||||
}
|
||||
|
||||
let result;
|
||||
try{
|
||||
result=record.handler(Object.freeze({opener,restoreFocus:settle}));
|
||||
}catch(error){
|
||||
fail(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
let then;
|
||||
try{
|
||||
then=result!==null&&(typeof result==='object'||typeof result==='function')?result.then:null;
|
||||
}catch(error){
|
||||
fail(error);
|
||||
return true;
|
||||
}
|
||||
if(typeof then==='function'){
|
||||
try{
|
||||
then.call(result,settle,fail);
|
||||
}catch(error){
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function turnLifecycleKey(sessionId,streamId){
|
||||
return `${sessionId}\u0000${streamId}`;
|
||||
}
|
||||
|
||||
function rememberTurnLifecycleState(key,state){
|
||||
if(turnLifecycleStates.has(key)) turnLifecycleStates.delete(key);
|
||||
turnLifecycleStates.set(key,state);
|
||||
while(turnLifecycleStates.size>TURN_LIFECYCLE_STATE_LIMIT){
|
||||
turnLifecycleStates.delete(turnLifecycleStates.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchTurnLifecycle(type,raw){
|
||||
if(!TURN_LIFECYCLE_TYPES.has(type)||!raw||typeof raw!=='object') return false;
|
||||
const sessionId=typeof raw.sessionId==='string'?raw.sessionId.trim():'';
|
||||
const streamId=typeof raw.streamId==='string'?raw.streamId.trim():'';
|
||||
if(!sessionId||!streamId) return false;
|
||||
|
||||
const key=turnLifecycleKey(sessionId,streamId);
|
||||
const previous=turnLifecycleStates.get(key)||{started:false,terminal:false};
|
||||
const terminal=type!=='turn:start';
|
||||
if((type==='turn:start'&&(previous.started||previous.terminal))||(terminal&&previous.terminal)) return false;
|
||||
rememberTurnLifecycleState(key,{
|
||||
started:previous.started||type==='turn:start',
|
||||
terminal:previous.terminal||terminal,
|
||||
});
|
||||
|
||||
const now=Date.now()/1000;
|
||||
const event={
|
||||
type,
|
||||
sessionId,
|
||||
streamId,
|
||||
timestamp:Number.isFinite(raw.timestamp)?raw.timestamp:now,
|
||||
};
|
||||
if(type==='turn:start') event.startedAt=Number.isFinite(raw.startedAt)?raw.startedAt:event.timestamp;
|
||||
else event.endedAt=Number.isFinite(raw.endedAt)?raw.endedAt:event.timestamp;
|
||||
if(typeof raw.status==='string'&&raw.status.trim()) event.status=raw.status.trim();
|
||||
const frozenEvent=Object.freeze(event);
|
||||
const listenersByExtension=turnLifecycleListeners.get(type);
|
||||
if(!listenersByExtension) return true;
|
||||
for(const [extensionId,listeners] of listenersByExtension){
|
||||
for(const listener of [...listeners]){
|
||||
try{
|
||||
listener(frozenEvent);
|
||||
}catch(error){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{
|
||||
console.error(`[Hermes extensions] ${extensionId} ${type} listener failed:`,error);
|
||||
}catch(_loggingError){ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function registerExtension(id){
|
||||
if(typeof id!=='string') return null;
|
||||
const clean=extensionId(id);
|
||||
if(!clean) return null;
|
||||
const existing=registrations.get(clean);
|
||||
if(existing) return existing;
|
||||
const trusted=trustedExtensions.get(clean);
|
||||
if(!trusted) return null;
|
||||
const handle=Object.freeze({
|
||||
id:clean,
|
||||
settings:settingsAccessor(clean,trusted,true,true),
|
||||
storage:storageAccessor(clean,trusted),
|
||||
events:eventAccessor(clean),
|
||||
});
|
||||
registrations.set(clean,handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
const api={
|
||||
normalizeSchemas,
|
||||
primeFromStatus,
|
||||
namespaceForExtension,
|
||||
settingsForExtension,
|
||||
storageForExtension,
|
||||
_dispatchTurnLifecycle:dispatchTurnLifecycle,
|
||||
_configureStateForExtension:configureStateForExtension,
|
||||
_invokeConfigure:invokeConfigure,
|
||||
_onConfigureChange:onConfigureChange,
|
||||
resetSettingsForExtension(id){return settingsForExtension(id).reset();},
|
||||
clearStorageForExtension(id){return storageForExtension(id).clear();},
|
||||
};
|
||||
@@ -325,5 +635,6 @@
|
||||
window.hermesExt.storage=window.hermesExt.storage||{};
|
||||
window.hermesExt.settings.forExtension=settingsForExtension;
|
||||
window.hermesExt.storage.forExtension=storageForExtension;
|
||||
window.hermesExt.register=registerExtension;
|
||||
primeFromStatus(window.__HERMES_EXTENSION_CONFIG__||{});
|
||||
})();
|
||||
|
||||
+256
-17
@@ -51,6 +51,9 @@ const LOCALES = {
|
||||
copy_failed: 'Copy failed',
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Append selected chat text as quoted context',
|
||||
selected_text_refine: 'Refine',
|
||||
selected_text_refine_title: 'Start an editable refinement draft from the selection',
|
||||
selected_text_refine_instruction: 'Refine instruction:',
|
||||
selected_text_reply_appended: 'Selected text added to composer',
|
||||
saved_prompts_empty: 'No saved prompts yet.',
|
||||
saved_prompts_save_current: 'Save current input as prompt',
|
||||
@@ -365,6 +368,7 @@ const LOCALES = {
|
||||
bg_no_answer:'(no answer)',
|
||||
bg_failed:'Background task failed: ',
|
||||
process_wakeup_label:'Background wakeup',
|
||||
process_wakeup_matched:'Watch pattern matched',
|
||||
undo_exchange:'Undo last exchange',
|
||||
cmd_status:'Show session info',
|
||||
cmd_voice:'Toggle microphone input',
|
||||
@@ -454,6 +458,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.',
|
||||
workspace_empty_dir: 'This workspace is empty.',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_sort_by: 'Sort by',
|
||||
workspace_sort_name_asc: 'Name (A → Z)',
|
||||
workspace_sort_name_desc: 'Name (Z → A)',
|
||||
workspace_sort_created_desc: 'Date created (newest first)',
|
||||
workspace_sort_modified_desc: 'Date modified (newest first)',
|
||||
workspace_sort_created_unavailable: 'Creation time is not reported by this server or platform.',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -889,6 +899,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Show kanban sessions',
|
||||
settings_label_previous_messaging_sessions: 'Show previous messaging sessions',
|
||||
settings_label_sync_insights: 'Sync to insights',
|
||||
settings_label_check_updates: 'Check for updates',
|
||||
@@ -1042,7 +1053,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_new_board: 'New board…',
|
||||
kanban_rename_board: 'Rename current board…',
|
||||
kanban_rename_board: 'Board settings…',
|
||||
kanban_board_settings: 'Board settings',
|
||||
kanban_archive_board: 'Archive current board…',
|
||||
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Board archived',
|
||||
@@ -1051,6 +1063,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (optional)',
|
||||
kanban_board_icon: 'Icon (emoji, optional)',
|
||||
kanban_board_color: 'Color (optional)',
|
||||
kanban_board_default_workdir: 'Default workspace path',
|
||||
kanban_board_default_workdir_placeholder: 'Workspace path (optional)',
|
||||
kanban_board_name_required: 'Name is required',
|
||||
kanban_board_slug_required: 'Slug is required',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -1222,6 +1236,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Surface kanban worker runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; worker runs are internal and can flood the sidebar.',
|
||||
settings_desc_previous_messaging_sessions: 'Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.',
|
||||
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
|
||||
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
|
||||
@@ -1567,6 +1582,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Drag to reorder',
|
||||
workspace_reorder_failed: 'Reorder failed',
|
||||
workspace_added: 'Workspace added',
|
||||
workspace_recovered_notice: (path) => `This session's deleted workspace was recovered to ${path}.`,
|
||||
workspace_renamed: 'Workspace renamed',
|
||||
workspace_remove_confirm_title: 'Remove workspace',
|
||||
workspace_remove_confirm_message: (path) => `Remove "${path}"?`,
|
||||
@@ -1812,6 +1828,9 @@ const LOCALES = {
|
||||
copy_failed: 'Copia fallita',
|
||||
selected_text_reply: 'Rispondi con selezione',
|
||||
selected_text_reply_title: 'Aggiungi il testo della chat selezionato come contesto citato',
|
||||
selected_text_refine: 'Affina',
|
||||
selected_text_refine_title: 'Inizia una bozza di affinamento modificabile dalla selezione',
|
||||
selected_text_refine_instruction: 'Istruzione di affinamento:',
|
||||
selected_text_reply_appended: 'Testo selezionato aggiunto al compositore',
|
||||
saved_prompts_empty: 'Nessun prompt salvato.',
|
||||
saved_prompts_save_current: 'Salva input corrente come prompt',
|
||||
@@ -2126,6 +2145,7 @@ const LOCALES = {
|
||||
bg_no_answer:'(nessuna risposta)',
|
||||
bg_failed:'Task in background fallito: ',
|
||||
process_wakeup_label:'Riattivazione in background',
|
||||
process_wakeup_matched:'Pattern di osservazione rilevato',
|
||||
undo_exchange:'Annulla ultimo scambio',
|
||||
cmd_status:'Mostra info sessione',
|
||||
cmd_voice:'Attiva/disattiva input microfono',
|
||||
@@ -2215,6 +2235,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Nessun workspace selezionato. Imposta un workspace in Impostazioni \u2192 Workspace per esplorare i file.',
|
||||
workspace_empty_dir: 'Questo workspace è vuoto.',
|
||||
workspace_show_hidden_files: 'Mostra file nascosti',
|
||||
workspace_sort_by: 'Ordina per',
|
||||
workspace_sort_name_asc: 'Nome (A → Z)',
|
||||
workspace_sort_name_desc: 'Nome (Z → A)',
|
||||
workspace_sort_created_desc: 'Data di creazione (più recenti prima)',
|
||||
workspace_sort_modified_desc: 'Data di modifica (più recenti prima)',
|
||||
workspace_sort_created_unavailable: 'L\'ora di creazione non è segnalata da questo server o piattaforma.',
|
||||
workspace_show_hidden_files_desc: "Includi .DS_Store, .git, node_modules e altri file nascosti/di sistema nell'albero dei file.",
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -2649,6 +2675,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Mostra sessioni kanban',
|
||||
settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti',
|
||||
settings_label_sync_insights: 'Sincronizza con insights',
|
||||
settings_label_check_updates: 'Verifica aggiornamenti',
|
||||
@@ -2802,7 +2829,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Torna alla bacheca',
|
||||
kanban_lanes_by_profile: 'Corsie per profilo',
|
||||
kanban_new_board: 'Nuova bacheca…',
|
||||
kanban_rename_board: 'Rinomina bacheca attuale…',
|
||||
kanban_rename_board: 'Impostazioni della bacheca…',
|
||||
kanban_board_settings: 'Impostazioni della bacheca',
|
||||
kanban_archive_board: 'Archivia bacheca attuale…',
|
||||
kanban_archive_board_confirm: 'Archiviare la bacheca "{name}"? I task rimangono su disco e la bacheca può essere ripristinata da kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Bacheca archiviata',
|
||||
@@ -2811,6 +2839,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Descrizione (opzionale)',
|
||||
kanban_board_icon: 'Icona (emoji, opzionale)',
|
||||
kanban_board_color: 'Colore (opzionale)',
|
||||
kanban_board_default_workdir: 'Percorso workspace predefinito',
|
||||
kanban_board_default_workdir_placeholder: 'Percorso workspace (opzionale)',
|
||||
kanban_board_name_required: 'Il nome è obbligatorio',
|
||||
kanban_board_slug_required: 'Lo slug è obbligatorio',
|
||||
kanban_card_complete: 'completato',
|
||||
@@ -2970,6 +3000,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Mostra le esecuzioni dei worker kanban come conversazioni nella barra laterale. Attivo solo quando le sessioni non-WebUI sono abilitate. Disattivato per impostazione predefinita; le esecuzioni dei worker sono interne e possono inondare la barra laterale.',
|
||||
settings_desc_previous_messaging_sessions: 'Mostra sessioni Discord, Telegram, Slack e Weixin più vecchie sostituite da reset o compressione.',
|
||||
settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.',
|
||||
settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.',
|
||||
@@ -3315,6 +3346,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Trascina per riordinare',
|
||||
workspace_reorder_failed: 'Riordino fallito',
|
||||
workspace_added: 'Workspace aggiunto',
|
||||
workspace_recovered_notice: (path) => `Il workspace eliminato di questa sessione è stato ripristinato in ${path}.`,
|
||||
workspace_renamed: 'Workspace rinominato',
|
||||
workspace_remove_confirm_title: 'Rimuovi workspace',
|
||||
workspace_remove_confirm_message: (path) => `Rimuovere "${path}"?`,
|
||||
@@ -3555,6 +3587,9 @@ const LOCALES = {
|
||||
copy_failed: 'コピー失敗',
|
||||
selected_text_reply: '選択範囲で返信',
|
||||
selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加',
|
||||
selected_text_refine: '推敲',
|
||||
selected_text_refine_title: '選択範囲から編集可能な推敲ドラフトを開始',
|
||||
selected_text_refine_instruction: '推敲指示:',
|
||||
selected_text_reply_appended: '選択したテキストを入力欄に追加しました',
|
||||
saved_prompts_empty: '保存済みプロンプトはありません。',
|
||||
saved_prompts_save_current: '現在の入力をプロンプトとして保存',
|
||||
@@ -3869,6 +3904,7 @@ const LOCALES = {
|
||||
bg_no_answer:'(回答なし)',
|
||||
bg_failed:'バックグラウンドタスク失敗: ',
|
||||
process_wakeup_label:'バックグラウンド起動',
|
||||
process_wakeup_matched:'監視パターンに一致',
|
||||
undo_exchange:'最後のやり取りを取り消す',
|
||||
cmd_status:'セッション情報を表示',
|
||||
cmd_voice:'マイク入力を切り替え',
|
||||
@@ -3958,6 +3994,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'ワークスペースが選択されていません。設定 → ワークスペースで選択してください。',
|
||||
workspace_empty_dir: 'このワークスペースは空です。',
|
||||
workspace_show_hidden_files: '隠しファイルを表示',
|
||||
workspace_sort_by: '並べ替え',
|
||||
workspace_sort_name_asc: '名前 (A → Z)',
|
||||
workspace_sort_name_desc: '名前 (Z → A)',
|
||||
workspace_sort_created_desc: '作成日 (新しい順)',
|
||||
workspace_sort_modified_desc: '更新日 (新しい順)',
|
||||
workspace_sort_created_unavailable: 'このサーバーまたはプラットフォームでは作成時刻が報告されません。',
|
||||
workspace_show_hidden_files_desc: '.DS_Store、.git、node_modules などの隠しファイル・システムファイルをファイルツリーに表示します。',
|
||||
workspace_panel_show: 'ワークスペースパネルを表示',
|
||||
workspace_panel_hide: 'ワークスペースパネルを非表示',
|
||||
@@ -4392,6 +4434,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Claude Code セッションを表示',
|
||||
settings_label_cron_sessions: 'Cronセッションを表示',
|
||||
settings_label_webhook_sessions: 'Webhookセッションを表示',
|
||||
settings_label_kanban_sessions: 'Kanbanセッションを表示',
|
||||
settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示',
|
||||
settings_label_sync_insights: 'インサイトに同期',
|
||||
settings_label_check_updates: 'アップデートを確認',
|
||||
@@ -4545,7 +4588,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'ボードに戻る',
|
||||
kanban_lanes_by_profile: 'プロファイル別レーン',
|
||||
kanban_new_board: '新規ボード…',
|
||||
kanban_rename_board: '現在のボード名を変更…',
|
||||
kanban_rename_board: 'ボード設定…',
|
||||
kanban_board_settings: 'ボード設定',
|
||||
kanban_archive_board: '現在のボードをアーカイブ…',
|
||||
kanban_archive_board_confirm: 'ボード "{name}" をアーカイブしますか? タスクはディスク上に残り、kanban/boards/_archived/ から復元できます。',
|
||||
kanban_board_archived: 'ボードをアーカイブしました',
|
||||
@@ -4554,6 +4598,8 @@ const LOCALES = {
|
||||
kanban_board_description: '説明 (任意)',
|
||||
kanban_board_icon: 'アイコン (絵文字、任意)',
|
||||
kanban_board_color: '色 (任意)',
|
||||
kanban_board_default_workdir: '既定のワークスペースパス',
|
||||
kanban_board_default_workdir_placeholder: 'ワークスペースのパス(任意)',
|
||||
kanban_board_name_required: '名前は必須です',
|
||||
kanban_board_slug_required: 'スラッグは必須です',
|
||||
kanban_card_complete: '完了',
|
||||
@@ -4718,6 +4764,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'サイドバーに Claude Code からインポートされたセッションを表示します。または、他の外部セッションは表示したまま Claude Code のセッションのみを非表示にします。',
|
||||
settings_desc_cron_sessions: 'Cronジョブの出力をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。高頻度のジョブはサイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_webhook_sessions: 'Webhook実行をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。高頻度のルートはサイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_kanban_sessions: 'Kanbanワーカーの実行をサイドバーの会話として表示します。WebUI以外のセッションが有効な場合のみ機能します。デフォルトはオフ。ワーカー実行は内部的なもので、サイドバーを溢れさせる可能性があります。',
|
||||
settings_desc_previous_messaging_sessions: 'reset または compression によって置き換えられた以前の Discord、Telegram、Slack、Weixin セッションを表示します。',
|
||||
settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。',
|
||||
settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。',
|
||||
@@ -5063,6 +5110,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'ドラッグして並び替え',
|
||||
workspace_reorder_failed: '並び替え失敗',
|
||||
workspace_added: 'ワークスペースを追加しました',
|
||||
workspace_recovered_notice: (path) => `このセッションの削除済みワークスペースを ${path} に復元しました。`,
|
||||
workspace_renamed: 'ワークスペース名を変更しました',
|
||||
workspace_remove_confirm_title: 'ワークスペースを削除',
|
||||
workspace_remove_confirm_message: (path) => `"${path}" を削除しますか?`,
|
||||
@@ -5169,9 +5217,9 @@ const LOCALES = {
|
||||
cron_deliver_label: '出力先',
|
||||
cron_deliver_local: 'ローカル (出力を保存のみ)',
|
||||
cron_deliver_custom: 'カスタム配信先',
|
||||
cron_profile_label: 'プロフィール',
|
||||
cron_profile_label: 'プロファイル',
|
||||
cron_profile_server_default: 'サーバーデフォルト',
|
||||
cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロフィールを使用します。プロフィールのない既存ジョブはこの従来の動作を維持します。',
|
||||
cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロファイルを使用します。プロファイルのない既存ジョブはこの従来の動作を維持します。',
|
||||
cron_toast_notifications_label: '完了トースト',
|
||||
cron_toast_notifications_hint: 'この Cron が完了したときにトーストを表示します。オフでもタスクバッジと新規実行マーカーは更新されます。',
|
||||
cron_toast_notifications_enabled: '有効',
|
||||
@@ -5300,6 +5348,9 @@ const LOCALES = {
|
||||
copy_failed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f',
|
||||
selected_text_reply: 'Ответить с выделенным',
|
||||
selected_text_reply_title: 'Добавить выделенный текст чата как цитируемый контекст',
|
||||
selected_text_refine: 'Доработать',
|
||||
selected_text_refine_title: 'Начать редактируемый черновик доработки из выделенного текста',
|
||||
selected_text_refine_instruction: 'Инструкция по доработке:',
|
||||
selected_text_reply_appended: 'Выделенный текст добавлен в поле ввода',
|
||||
saved_prompts_empty: 'Сохранённых подсказок пока нет.',
|
||||
saved_prompts_save_current: 'Сохранить текущий ввод как подсказку',
|
||||
@@ -5541,6 +5592,7 @@ const LOCALES = {
|
||||
cmd_background: 'Запустить промпт в фоне',
|
||||
cmd_background_usage: '/background <промпт> — запустить параллельно, не блокируя чат',
|
||||
process_wakeup_label: 'Фоновое пробуждение',
|
||||
process_wakeup_matched: 'Совпадение с шаблоном наблюдения',
|
||||
|
||||
no_personalities: 'Личности не найдены (добавьте их в ~/.hermes/personalities/)',
|
||||
clarify_heading: 'Требуется уточнение',
|
||||
@@ -5612,6 +5664,12 @@ const LOCALES = {
|
||||
settings_autosave_retry: 'Повторить',
|
||||
workspace_empty_dir: 'Это рабочее пространство пусто.',
|
||||
workspace_show_hidden_files: 'Показывать скрытые файлы',
|
||||
workspace_sort_by: 'Сортировать по',
|
||||
workspace_sort_name_asc: 'Имя (A → Z)',
|
||||
workspace_sort_name_desc: 'Имя (Z → A)',
|
||||
workspace_sort_created_desc: 'Дата создания (сначала новые)',
|
||||
workspace_sort_modified_desc: 'Дата изменения (сначала новые)',
|
||||
workspace_sort_created_unavailable: 'Время создания не сообщается этим сервером или платформой.',
|
||||
workspace_show_hidden_files_desc: 'Показывать .DS_Store, .git, node_modules и другие скрытые или системные файлы в дереве.',
|
||||
workspace_panel_show: 'Показать панель рабочей области',
|
||||
workspace_panel_hide: 'Скрыть панель рабочей области',
|
||||
@@ -5839,6 +5897,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Показывать сеансы Claude Code',
|
||||
settings_label_cron_sessions: 'Показывать cron-сеансы',
|
||||
settings_label_webhook_sessions: 'Показывать webhook-сеансы',
|
||||
settings_label_kanban_sessions: 'Показывать kanban-сеансы',
|
||||
settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями',
|
||||
settings_label_sync_insights: 'Синхронизировать с Insights',
|
||||
settings_label_check_updates: 'Проверять обновления',
|
||||
@@ -5982,7 +6041,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Назад к доске',
|
||||
kanban_lanes_by_profile: 'Дорожки по профилям',
|
||||
kanban_new_board: 'Новая доска…',
|
||||
kanban_rename_board: 'Переименовать текущую доску…',
|
||||
kanban_rename_board: 'Настройки доски…',
|
||||
kanban_board_settings: 'Настройки доски',
|
||||
kanban_archive_board: 'Архивировать текущую доску…',
|
||||
kanban_archive_board_confirm: 'Архивировать доску «{name}»? Задачи останутся на диске, а доску можно будет восстановить из kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Доска архивирована',
|
||||
@@ -5991,6 +6051,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Описание (необязательно)',
|
||||
kanban_board_icon: 'Иконка (emoji, необязательно)',
|
||||
kanban_board_color: 'Цвет (необязательно)',
|
||||
kanban_board_default_workdir: 'Путь рабочего пространства по умолчанию',
|
||||
kanban_board_default_workdir_placeholder: 'Путь к workspace (необязательно)',
|
||||
kanban_board_name_required: 'Укажите имя',
|
||||
kanban_board_slug_required: 'Укажите slug',
|
||||
kanban_card_complete: 'завершить',
|
||||
@@ -6090,6 +6152,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Показывать импортированные сеансы Claude Code в боковой панели или скрывать их, оставляя другие внешние сеансы видимыми.',
|
||||
settings_desc_cron_sessions: 'Показывать вывод cron-заданий как беседы в боковой панели. Работает только при включённых не-WebUI сеансах. По умолчанию выключено; частые задания могут переполнить боковую панель.',
|
||||
settings_desc_webhook_sessions: 'Показывать запуски webhook как беседы в боковой панели. Работает только при включённых не-WebUI сеансах. По умолчанию выключено; высокочастотные маршруты могут переполнить боковую панель.',
|
||||
settings_desc_kanban_sessions: 'Показывать запуски kanban-воркеров как беседы в боковой панели. Работает только при включённых не-WebUI сеансах. По умолчанию выключено; запуски воркеров — внутренние и могут переполнить боковую панель.',
|
||||
settings_desc_previous_messaging_sessions: 'Показывать предыдущие сеансы Discord, Telegram, Slack и Weixin, замененные сбросом или сжатием.',
|
||||
settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.',
|
||||
settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.',
|
||||
@@ -6418,6 +6481,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Перетащите для изменения порядка',
|
||||
workspace_reorder_failed: 'Не удалось изменить порядок',
|
||||
workspace_added: 'Рабочее пространство добавлено',
|
||||
workspace_recovered_notice: (path) => `Удалённое рабочее пространство этого сеанса восстановлено в ${path}.`,
|
||||
workspace_renamed: 'Рабочее пространство переименовано',
|
||||
workspace_remove_confirm_title: 'Удалить рабочее пространство',
|
||||
workspace_remove_confirm_message: (path) => `Удалить «${path}»?`,
|
||||
@@ -7010,6 +7074,9 @@ const LOCALES = {
|
||||
copy_failed: 'Error al copiar',
|
||||
selected_text_reply: 'Responder con selección',
|
||||
selected_text_reply_title: 'Añadir el texto del chat seleccionado como contexto citado',
|
||||
selected_text_refine: 'Refinar',
|
||||
selected_text_refine_title: 'Iniciar un borrador de refinado editable a partir de la selección',
|
||||
selected_text_refine_instruction: 'Instrucción de refinado:',
|
||||
selected_text_reply_appended: 'Texto seleccionado añadido al compositor',
|
||||
saved_prompts_empty: 'No hay indicaciones guardadas todavía.',
|
||||
saved_prompts_save_current: 'Guardar entrada actual como indicación',
|
||||
@@ -7300,6 +7367,7 @@ const LOCALES = {
|
||||
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
|
||||
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
|
||||
process_wakeup_label: 'Activación en segundo plano',
|
||||
process_wakeup_matched: 'Patrón de observación coincidente',
|
||||
no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)',
|
||||
available_personalities: 'Personalidades disponibles:',
|
||||
personality_switch_hint: '\n\nUsa `/personality <name>` para cambiar, o `/personality none` para limpiar.',
|
||||
@@ -7327,6 +7395,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.',
|
||||
workspace_empty_dir: 'Este espacio de trabajo está vacío.',
|
||||
workspace_show_hidden_files: 'Mostrar archivos ocultos',
|
||||
workspace_sort_by: 'Ordenar por',
|
||||
workspace_sort_name_asc: 'Nombre (A → Z)',
|
||||
workspace_sort_name_desc: 'Nombre (Z → A)',
|
||||
workspace_sort_created_desc: 'Fecha de creación (más recientes primero)',
|
||||
workspace_sort_modified_desc: 'Fecha de modificación (más recientes primero)',
|
||||
workspace_sort_created_unavailable: 'Este servidor o plataforma no informa la hora de creación.',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -7535,6 +7609,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Mostrar sesiones kanban',
|
||||
settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores',
|
||||
settings_label_sync_insights: 'Sincronizar con insights',
|
||||
settings_label_check_updates: 'Buscar actualizaciones',
|
||||
@@ -7688,7 +7763,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_new_board: 'New board…',
|
||||
kanban_rename_board: 'Rename current board…',
|
||||
kanban_rename_board: 'Configuración del tablero…',
|
||||
kanban_board_settings: 'Configuración del tablero',
|
||||
kanban_archive_board: 'Archive current board…',
|
||||
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Board archived',
|
||||
@@ -7697,6 +7773,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (optional)',
|
||||
kanban_board_icon: 'Icon (emoji, optional)',
|
||||
kanban_board_color: 'Color (optional)',
|
||||
kanban_board_default_workdir: 'Ruta de workspace predeterminada',
|
||||
kanban_board_default_workdir_placeholder: 'Ruta del workspace (opcional)',
|
||||
kanban_board_name_required: 'Name is required',
|
||||
kanban_board_slug_required: 'Slug is required',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -7797,6 +7875,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Mostrar las ejecuciones de los workers kanban como conversaciones en la barra lateral. Solo activo cuando las sesiones no-WebUI están habilitadas. Desactivado por defecto; las ejecuciones de workers son internas y pueden inundar la barra lateral.',
|
||||
settings_desc_previous_messaging_sessions: 'Mostrar sesiones antiguas de Discord, Telegram, Slack y Weixin reemplazadas por reset o compresión.',
|
||||
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
|
||||
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
|
||||
@@ -8142,6 +8221,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Arrastra para reordenar',
|
||||
workspace_reorder_failed: 'Error al reordenar',
|
||||
workspace_added: 'Workspace added',
|
||||
workspace_recovered_notice: (path) => `El espacio de trabajo eliminado de esta sesión se recuperó en ${path}.`,
|
||||
workspace_renamed: 'Espacio renombrado',
|
||||
workspace_remove_confirm_title: 'Remove workspace',
|
||||
workspace_remove_confirm_message: (path) => `Remove "${path}"?`,
|
||||
@@ -8699,6 +8779,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopieren fehlgeschlagen',
|
||||
selected_text_reply: 'Mit Auswahl antworten',
|
||||
selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen',
|
||||
selected_text_refine: 'Überarbeiten',
|
||||
selected_text_refine_title: 'Einen bearbeitbaren Überarbeitungsentwurf aus der Auswahl starten',
|
||||
selected_text_refine_instruction: 'Anweisung zur Überarbeitung:',
|
||||
selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt',
|
||||
saved_prompts_empty: 'Noch keine gespeicherten Eingabeaufforderungen.',
|
||||
saved_prompts_save_current: 'Aktuelle Eingabe als Eingabeaufforderung speichern',
|
||||
@@ -8958,6 +9041,7 @@ const LOCALES = {
|
||||
composer_placeholder_busy_interrupt: 'Enter = interrupt | /queue | /background | /steer',
|
||||
composer_placeholder_busy_steer: 'Enter = steer | /queue | /background | /interrupt',
|
||||
process_wakeup_label: 'Hintergrund-Aktivierung',
|
||||
process_wakeup_matched: 'Überwachungsmuster erkannt',
|
||||
no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)',
|
||||
available_personalities: 'Verfügbare Persönlichkeiten:',
|
||||
personality_switch_hint: '\n\nNutze `/personality <name>` zum Wechseln, oder `/personality none` zum Löschen.',
|
||||
@@ -8985,6 +9069,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Kein Workspace ausgewählt. Wähle einen Workspace unter Einstellungen \u2192 Workspace, um Dateien zu durchsuchen.',
|
||||
workspace_empty_dir: 'Dieser Workspace ist leer.',
|
||||
workspace_show_hidden_files: 'Versteckte Dateien anzeigen',
|
||||
workspace_sort_by: 'Sortieren nach',
|
||||
workspace_sort_name_asc: 'Name (A → Z)',
|
||||
workspace_sort_name_desc: 'Name (Z → A)',
|
||||
workspace_sort_created_desc: 'Erstellungsdatum (neueste zuerst)',
|
||||
workspace_sort_modified_desc: 'Änderungsdatum (neueste zuerst)',
|
||||
workspace_sort_created_unavailable: 'Die Erstellungszeit wird von diesem Server oder dieser Plattform nicht gemeldet.',
|
||||
workspace_show_hidden_files_desc: '.DS_Store, .git, node_modules und weitere versteckte / Systemdateien im Dateibaum anzeigen.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -9200,6 +9290,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Kanban-Sitzungen anzeigen',
|
||||
settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen',
|
||||
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
||||
settings_label_check_updates: 'Nach Updates suchen',
|
||||
@@ -9353,7 +9444,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_new_board: 'New board…',
|
||||
kanban_rename_board: 'Rename current board…',
|
||||
kanban_rename_board: 'Board-Einstellungen…',
|
||||
kanban_board_settings: 'Board-Einstellungen',
|
||||
kanban_archive_board: 'Archive current board…',
|
||||
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Board archived',
|
||||
@@ -9362,6 +9454,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (optional)',
|
||||
kanban_board_icon: 'Icon (emoji, optional)',
|
||||
kanban_board_color: 'Color (optional)',
|
||||
kanban_board_default_workdir: 'Standard-Workspace-Pfad',
|
||||
kanban_board_default_workdir_placeholder: 'Workspace-Pfad (optional)',
|
||||
kanban_board_name_required: 'Name is required',
|
||||
kanban_board_slug_required: 'Slug is required',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -9452,6 +9546,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Kanban-Worker-Läufe als Unterhaltungen in der Seitenleiste anzeigen. Nur aktiv, wenn Nicht-WebUI-Sitzungen aktiviert sind. Standardmäßig aus; Worker-Läufe sind intern und können die Seitenleiste überfluten.',
|
||||
settings_desc_previous_messaging_sessions: 'Zeigt ältere Discord-, Telegram-, Slack- und Weixin-Sitzungen, die durch Reset oder Compression ersetzt wurden.',
|
||||
settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.',
|
||||
settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.',
|
||||
@@ -10156,6 +10251,7 @@ const LOCALES = {
|
||||
workspace_add_path_placeholder: '~/projects/my-app',
|
||||
workspace_paths_validated_hint: 'Pfad ist gültig.',
|
||||
workspace_added: 'Arbeitsbereich hinzugefügt.',
|
||||
workspace_recovered_notice: (path) => `Der gelöschte Arbeitsbereich dieser Sitzung wurde unter ${path} wiederhergestellt.`,
|
||||
workspace_renamed: 'Arbeitsbereich umbenannt.',
|
||||
workspace_remove_confirm_title: 'Arbeitsbereich entfernen',
|
||||
workspace_remove_confirm_message: 'Arbeitsbereich entfernen?',
|
||||
@@ -10382,6 +10478,9 @@ const LOCALES = {
|
||||
copy_failed: '复制失败',
|
||||
selected_text_reply: '用所选内容回复',
|
||||
selected_text_reply_title: '将所选聊天文本作为引用上下文追加',
|
||||
selected_text_refine: '润色',
|
||||
selected_text_refine_title: '根据所选内容开始可编辑的润色草稿',
|
||||
selected_text_refine_instruction: '润色说明:',
|
||||
selected_text_reply_appended: '已将所选文本添加到输入框',
|
||||
saved_prompts_empty: '暂无保存的提示。',
|
||||
saved_prompts_save_current: '将当前输入保存为提示',
|
||||
@@ -10671,6 +10770,7 @@ const LOCALES = {
|
||||
composer_placeholder_busy_interrupt: 'Enter = 中断 | /queue | /background | /steer',
|
||||
composer_placeholder_busy_steer: 'Enter = 引导 | /queue | /background | /interrupt',
|
||||
process_wakeup_label: '后台唤醒',
|
||||
process_wakeup_matched: '监视模式已匹配',
|
||||
settings_plugins_title: '插件',
|
||||
plugins_enable_toggle: '启用',
|
||||
settings_plugins_meta: '查看已安装的 Hermes 插件及其注册的生命周期挂钩。此面板为只读。',
|
||||
@@ -10687,6 +10787,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。',
|
||||
workspace_empty_dir: '此工作区为空。',
|
||||
workspace_show_hidden_files: '显示隐藏文件',
|
||||
workspace_sort_by: '排序方式',
|
||||
workspace_sort_name_asc: '名称 (A → Z)',
|
||||
workspace_sort_name_desc: '名称 (Z → A)',
|
||||
workspace_sort_created_desc: '创建日期 (最新优先)',
|
||||
workspace_sort_modified_desc: '修改日期 (最新优先)',
|
||||
workspace_sort_created_unavailable: '此服务器或平台未报告创建时间。',
|
||||
workspace_show_hidden_files_desc: '将 .DS_Store、.git、node_modules 以及其他隐藏文件/系统文件包含在文件树中。',
|
||||
workspace_panel_show: '显示工作区面板',
|
||||
workspace_panel_hide: '隐藏工作区面板',
|
||||
@@ -10926,6 +11032,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: '显示 Claude Code 会话',
|
||||
settings_label_cron_sessions: '显示定时任务会话',
|
||||
settings_label_webhook_sessions: '显示Webhook会话',
|
||||
settings_label_kanban_sessions: '显示Kanban会话',
|
||||
settings_label_previous_messaging_sessions: '显示以前的消息会话',
|
||||
settings_label_sync_insights: '同步到 insights',
|
||||
settings_label_check_updates: '检查更新',
|
||||
@@ -11069,7 +11176,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: '返回看板',
|
||||
kanban_lanes_by_profile: '按配置文件分列',
|
||||
kanban_new_board: '新建看板…',
|
||||
kanban_rename_board: '重命名当前看板…',
|
||||
kanban_rename_board: '看板设置…',
|
||||
kanban_board_settings: '看板设置',
|
||||
kanban_archive_board: '归档当前看板…',
|
||||
kanban_archive_board_confirm: '归档看板 "{name}"?任务仍保存在磁盘上,并且可以从 kanban/boards/_archived/ 恢复该看板。',
|
||||
kanban_board_archived: '看板已归档',
|
||||
@@ -11078,6 +11186,8 @@ const LOCALES = {
|
||||
kanban_board_description: '描述(可选)',
|
||||
kanban_board_icon: '图标(emoji,可选)',
|
||||
kanban_board_color: '颜色(可选)',
|
||||
kanban_board_default_workdir: '默认工作区路径',
|
||||
kanban_board_default_workdir_placeholder: '工作区路径(可选)',
|
||||
kanban_board_name_required: '名称为必填',
|
||||
kanban_board_slug_required: '标识为必填',
|
||||
kanban_card_complete: '完成',
|
||||
@@ -11270,6 +11380,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: '在侧边栏中显示已导入的Claude Code会话,或者隐藏它们,同时保持其他外部会话可见。',
|
||||
settings_desc_cron_sessions: '将定时任务输出以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;高频任务可能会使侧边栏信息泛滥。',
|
||||
settings_desc_webhook_sessions: '将Webhook运行情况以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;大量的路由信息可能会使侧边栏信息泛滥。',
|
||||
settings_desc_kanban_sessions: '将Kanban工作线程的运行情况以对话形式显示在侧边栏。仅在启用非 WebUI 会话时此功能才会生效。默认关闭;工作线程运行是内部操作,可能会使侧边栏信息泛滥。',
|
||||
settings_desc_previous_messaging_sessions: '显示被 reset 或 compression 替换的较旧的 Discord、Telegram、Slack 和 Weixin 会话。',
|
||||
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。',
|
||||
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
|
||||
@@ -11509,6 +11620,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: '拖动以重新排序',
|
||||
workspace_reorder_failed: '重新排序失败',
|
||||
workspace_added: '工作区已添加',
|
||||
workspace_recovered_notice: (path) => `此会话已删除的工作区已恢复到 ${path}。`,
|
||||
workspace_renamed: '工作区已重命名',
|
||||
workspace_remove_confirm_title: '移除工作区',
|
||||
workspace_remove_confirm_message: (path) => `要移除"${path}"吗?`,
|
||||
@@ -12075,6 +12187,9 @@ const LOCALES = {
|
||||
copy_failed: '複製失敗',
|
||||
selected_text_reply: '用所選內容回覆',
|
||||
selected_text_reply_title: '將所選聊天文字附加為引用脈絡',
|
||||
selected_text_refine: '潤飾',
|
||||
selected_text_refine_title: '根據所選內容開始可編輯的潤飾草稿',
|
||||
selected_text_refine_instruction: '潤飾指示:',
|
||||
selected_text_reply_appended: '已將所選文字加入輸入框',
|
||||
saved_prompts_empty: '尚無已儲存的提示。',
|
||||
saved_prompts_save_current: '將目前輸入儲存為提示',
|
||||
@@ -12388,6 +12503,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(沒有回答)',
|
||||
bg_failed: '背景任務失敗:',
|
||||
process_wakeup_label: '背景喚醒',
|
||||
process_wakeup_matched: '監視模式已匹配',
|
||||
undo_exchange: '復原上一組問答',
|
||||
cmd_status: '顯示對話資訊',
|
||||
cmd_voice: '切換麥克風輸入',
|
||||
@@ -12477,6 +12593,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。',
|
||||
workspace_empty_dir: '此工作區為空。',
|
||||
workspace_show_hidden_files: '顯示隱藏檔案',
|
||||
workspace_sort_by: '排序方式',
|
||||
workspace_sort_name_asc: '名稱 (A → Z)',
|
||||
workspace_sort_name_desc: '名稱 (Z → A)',
|
||||
workspace_sort_created_desc: '建立日期 (最新優先)',
|
||||
workspace_sort_modified_desc: '修改日期 (最新優先)',
|
||||
workspace_sort_created_unavailable: '此伺服器或平台未回報建立時間。',
|
||||
workspace_show_hidden_files_desc: '在檔案樹中包含 .DS_Store、.git、node_modules 與其他隱藏/系統檔案。',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -12905,6 +13027,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: '顯示Kanban工作階段',
|
||||
settings_label_previous_messaging_sessions: '顯示以前的訊息對話',
|
||||
settings_label_sync_insights: '同步至 insights',
|
||||
settings_label_check_updates: '檢查更新',
|
||||
@@ -13058,7 +13181,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: '回到看板',
|
||||
kanban_lanes_by_profile: '依設定檔分欄',
|
||||
kanban_new_board: '新增看板…',
|
||||
kanban_rename_board: '重新命名目前看板…',
|
||||
kanban_rename_board: '看板設定…',
|
||||
kanban_board_settings: '看板設定',
|
||||
kanban_archive_board: '封存目前看板…',
|
||||
kanban_archive_board_confirm: '封存看板「{name}」?任務仍會保留在磁碟上,且可從 kanban/boards/_archived/ 還原看板。',
|
||||
kanban_board_archived: '看板已封存',
|
||||
@@ -13067,6 +13191,8 @@ const LOCALES = {
|
||||
kanban_board_description: '描述(選填)',
|
||||
kanban_board_icon: '圖示(emoji,選填)',
|
||||
kanban_board_color: '顏色(選填)',
|
||||
kanban_board_default_workdir: '預設工作區路徑',
|
||||
kanban_board_default_workdir_placeholder: '工作區路徑(選填)',
|
||||
kanban_board_name_required: '名稱為必填',
|
||||
kanban_board_slug_required: 'Slug 為必填',
|
||||
kanban_card_complete: '完成',
|
||||
@@ -13234,6 +13360,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: '將Kanban worker 的執行顯示為側邊欄中的對話。僅在啟用非 WebUI 工作階段時才有效。預設為關閉;worker 執行是內部的,可能會使側邊欄氾濫。',
|
||||
settings_desc_previous_messaging_sessions: '顯示被 reset 或 compression 替換的較舊的 Discord、Telegram、Slack 和 Weixin 對話。',
|
||||
settings_desc_sync_insights: '將 WebUI token 用量鏡射到 state.db,讓 hermes /insights 納入瀏覽器工作階段資料。預設為關閉。',
|
||||
settings_desc_check_updates: '有較新版 WebUI 或 Agent 時顯示橫幅。系統會定期在背景執行 git fetch。',
|
||||
@@ -13579,6 +13706,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: '拖曳以重新排列',
|
||||
workspace_reorder_failed: '重新排列失敗',
|
||||
workspace_added: '工作區已新增',
|
||||
workspace_recovered_notice: (path) => `此工作階段已刪除的工作區已復原至 ${path}。`,
|
||||
workspace_renamed: '工作區已重新命名',
|
||||
workspace_remove_confirm_title: '移除工作區',
|
||||
workspace_remove_confirm_message: (name) => `移除工作區「${name}」?`,
|
||||
@@ -13805,6 +13933,9 @@ const LOCALES = {
|
||||
copy_failed: 'Falha ao copiar',
|
||||
selected_text_reply: 'Responder com seleção',
|
||||
selected_text_reply_title: 'Anexar o texto selecionado do chat como contexto citado',
|
||||
selected_text_refine: 'Refinar',
|
||||
selected_text_refine_title: 'Iniciar um rascunho de refinamento editável a partir da seleção',
|
||||
selected_text_refine_instruction: 'Instrução de refinamento:',
|
||||
selected_text_reply_appended: 'Texto selecionado adicionado ao compositor',
|
||||
saved_prompts_empty: 'Nenhum prompt salvo ainda.',
|
||||
saved_prompts_save_current: 'Salvar entrada atual como prompt',
|
||||
@@ -14057,6 +14188,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(sem resposta)',
|
||||
bg_failed: 'Tarefa de background falhou: ',
|
||||
process_wakeup_label: 'Reativação em background',
|
||||
process_wakeup_matched: 'Padrão de observação correspondido',
|
||||
undo_exchange: 'Desfazer última troca',
|
||||
cmd_status: 'Mostrar info da sessão',
|
||||
cmd_voice: 'Alternar input de microfone',
|
||||
@@ -14115,6 +14247,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Nenhum workspace selecionado. Configure em Configurações → Workspace.',
|
||||
workspace_empty_dir: 'Este workspace está vazio.',
|
||||
workspace_show_hidden_files: 'Mostrar arquivos ocultos',
|
||||
workspace_sort_by: 'Ordenar por',
|
||||
workspace_sort_name_asc: 'Nome (A → Z)',
|
||||
workspace_sort_name_desc: 'Nome (Z → A)',
|
||||
workspace_sort_created_desc: 'Data de criação (mais recentes primeiro)',
|
||||
workspace_sort_modified_desc: 'Data de modificação (mais recentes primeiro)',
|
||||
workspace_sort_created_unavailable: 'A hora de criação não é informada por este servidor ou plataforma.',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -14541,6 +14679,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Mostrar sessões kanban',
|
||||
settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores',
|
||||
settings_label_sync_insights: 'Sincronizar para insights',
|
||||
settings_label_check_updates: 'Verificar atualizações',
|
||||
@@ -14687,7 +14826,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_new_board: 'New board…',
|
||||
kanban_rename_board: 'Rename current board…',
|
||||
kanban_rename_board: 'Configurações do quadro…',
|
||||
kanban_board_settings: 'Configurações do quadro',
|
||||
kanban_archive_board: 'Archive current board…',
|
||||
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Board archived',
|
||||
@@ -14696,6 +14836,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (optional)',
|
||||
kanban_board_icon: 'Icon (emoji, optional)',
|
||||
kanban_board_color: 'Color (optional)',
|
||||
kanban_board_default_workdir: 'Caminho padrão do workspace',
|
||||
kanban_board_default_workdir_placeholder: 'Caminho do workspace (opcional)',
|
||||
kanban_board_name_required: 'Name is required',
|
||||
kanban_board_slug_required: 'Slug is required',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -14796,6 +14938,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Mostrar execuções dos workers kanban como conversas na barra lateral. Ativo apenas quando as sessões não-WebUI estão habilitadas. Desativado por padrão; execuções de workers são internas e podem inundar a barra lateral.',
|
||||
settings_desc_previous_messaging_sessions: 'Mostrar sessões antigas de Discord, Telegram, Slack e Weixin substituídas por reset ou compressão.',
|
||||
settings_desc_sync_insights: 'Espelha uso de tokens para state.db.',
|
||||
settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.',
|
||||
@@ -15368,6 +15511,9 @@ const LOCALES = {
|
||||
copy_failed: '복사 실패',
|
||||
selected_text_reply: '선택 항목으로 답장',
|
||||
selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가',
|
||||
selected_text_refine: '다듬기',
|
||||
selected_text_refine_title: '선택한 내용으로 편집 가능한 다듬기 초안을 시작',
|
||||
selected_text_refine_instruction: '다듬기 지시:',
|
||||
selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다',
|
||||
saved_prompts_empty: '저장된 프롬프트가 없습니다.',
|
||||
saved_prompts_save_current: '현재 입력을 프롬프트로 저장',
|
||||
@@ -15677,6 +15823,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(답변 없음)',
|
||||
bg_failed: '백그라운드 작업 실패: ',
|
||||
process_wakeup_label: '백그라운드 깨우기',
|
||||
process_wakeup_matched: '감시 패턴 일치',
|
||||
undo_exchange: '마지막 교환 실행 취소',
|
||||
cmd_status: '세션 정보 표시',
|
||||
cmd_voice: '마이크 입력 전환',
|
||||
@@ -15749,6 +15896,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.',
|
||||
workspace_empty_dir: 'This workspace is empty.',
|
||||
workspace_show_hidden_files: '숨김 파일 표시',
|
||||
workspace_sort_by: '정렬 기준',
|
||||
workspace_sort_name_asc: '이름 (A → Z)',
|
||||
workspace_sort_name_desc: '이름 (Z → A)',
|
||||
workspace_sort_created_desc: '생성 날짜 (최신순)',
|
||||
workspace_sort_modified_desc: '수정 날짜 (최신순)',
|
||||
workspace_sort_created_unavailable: '이 서버 또는 플랫폼에서 생성 시간을 보고하지 않습니다.',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -16183,6 +16336,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Kanban 세션 표시',
|
||||
settings_label_previous_messaging_sessions: '이전 메시징 세션 표시',
|
||||
settings_label_sync_insights: 'Insights에 동기화',
|
||||
settings_label_check_updates: '업데이트 확인',
|
||||
@@ -16328,7 +16482,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_new_board: 'New board…',
|
||||
kanban_rename_board: 'Rename current board…',
|
||||
kanban_rename_board: '보드 설정…',
|
||||
kanban_board_settings: '보드 설정',
|
||||
kanban_archive_board: 'Archive current board…',
|
||||
kanban_archive_board_confirm: 'Archive board "{name}"? Tasks remain on disk and the board can be restored from kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Board archived',
|
||||
@@ -16337,6 +16492,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (optional)',
|
||||
kanban_board_icon: 'Icon (emoji, optional)',
|
||||
kanban_board_color: 'Color (optional)',
|
||||
kanban_board_default_workdir: '기본 워크스페이스 경로',
|
||||
kanban_board_default_workdir_placeholder: '워크스페이스 경로(선택 사항)',
|
||||
kanban_board_name_required: 'Name is required',
|
||||
kanban_board_slug_required: 'Slug is required',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -16437,6 +16594,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Kanban 워커 실행을 사이드바의 대화로 표시합니다. 비 WebUI 세션이 활성화된 경우에만 작동합니다. 기본적으로 꺼져 있습니다. 워커 실행은 내부 작업이며 사이드바를 넘칠 수 있습니다.',
|
||||
settings_desc_previous_messaging_sessions: 'reset 또는 compression으로 교체된 이전 Discord, Telegram, Slack, Weixin 세션을 표시합니다.',
|
||||
settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.',
|
||||
settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.',
|
||||
@@ -16782,6 +16940,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: '드래그하여 순서 변경',
|
||||
workspace_reorder_failed: '순서 변경 실패',
|
||||
workspace_added: 'Workspace added',
|
||||
workspace_recovered_notice: (path) => `이 세션에서 삭제된 작업 공간을 ${path}(으)로 복구했습니다.`,
|
||||
workspace_renamed: 'Workspace renamed',
|
||||
workspace_remove_confirm_title: 'Remove workspace',
|
||||
workspace_remove_confirm_message: (path) => `Remove "${path}"?`,
|
||||
@@ -17112,6 +17271,9 @@ const LOCALES = {
|
||||
copy_failed: 'Échec de la copie',
|
||||
selected_text_reply: 'Répondre avec la sélection',
|
||||
selected_text_reply_title: 'Ajouter le texte sélectionné du chat comme contexte cité',
|
||||
selected_text_refine: 'Affiner',
|
||||
selected_text_refine_title: 'Commencer un brouillon d\'affinage modifiable à partir de la sélection',
|
||||
selected_text_refine_instruction: 'Instruction d\'affinage :',
|
||||
selected_text_reply_appended: 'Texte sélectionné ajouté au compositeur',
|
||||
saved_prompts_empty: 'Aucune invite sauvegardée pour l\'instant.',
|
||||
saved_prompts_save_current: 'Sauvegarder la saisie actuelle comme invite',
|
||||
@@ -17421,6 +17583,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(aucune réponse)',
|
||||
bg_failed: 'Échec de la tâche en arrière-plan : ',
|
||||
process_wakeup_label: 'Réveil en arrière-plan',
|
||||
process_wakeup_matched: 'Motif de surveillance détecté',
|
||||
undo_exchange: 'Annuler le dernier échange',
|
||||
cmd_status: 'Afficher les informations de la session',
|
||||
cmd_voice: 'Activer/désactiver la saisie microphone',
|
||||
@@ -17508,6 +17671,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Aucun espace de travail sélectionné. Définissez un espace de travail dans Paramètres → Espace de travail pour parcourir les fichiers.',
|
||||
workspace_empty_dir: 'Cet espace de travail est vide.',
|
||||
workspace_show_hidden_files: 'Afficher les fichiers cachés',
|
||||
workspace_sort_by: 'Trier par',
|
||||
workspace_sort_name_asc: 'Nom (A → Z)',
|
||||
workspace_sort_name_desc: 'Nom (Z → A)',
|
||||
workspace_sort_created_desc: 'Date de création (plus récentes d\'abord)',
|
||||
workspace_sort_modified_desc: 'Date de modification (plus récentes d\'abord)',
|
||||
workspace_sort_created_unavailable: 'L\'heure de création n\'est pas indiquée par ce serveur ou cette plateforme.',
|
||||
workspace_show_hidden_files_desc: 'Inclure .DS_Store, .git, node_modules et autres fichiers cachés/système dans l\'arborescence.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -17940,6 +18109,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Afficher les sessions cron',
|
||||
settings_label_webhook_sessions: 'Afficher les sessions webhook',
|
||||
settings_label_kanban_sessions: 'Afficher les sessions kanban',
|
||||
settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes',
|
||||
settings_label_sync_insights: 'Synchroniser avec les analyses',
|
||||
settings_label_check_updates: 'Vérifier les mises à jour',
|
||||
@@ -18091,7 +18261,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Retour au tableau',
|
||||
kanban_lanes_by_profile: 'Colonnes par profil',
|
||||
kanban_new_board: 'Nouveau tableau\u2026',
|
||||
kanban_rename_board: 'Renommer le tableau actuel\u2026',
|
||||
kanban_rename_board: 'Paramètres du tableau\u2026',
|
||||
kanban_board_settings: 'Paramètres du tableau',
|
||||
kanban_archive_board: 'Archiver le tableau actuel\u2026',
|
||||
kanban_archive_board_confirm: 'Archiver le tableau "{name}" ? Les tâches restent sur le disque et le tableau peut être restauré depuis kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Tableau archivé',
|
||||
@@ -18100,6 +18271,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Description (facultatif)',
|
||||
kanban_board_icon: 'Icône (emoji, facultatif)',
|
||||
kanban_board_color: 'Couleur (facultatif)',
|
||||
kanban_board_default_workdir: 'Chemin du workspace par défaut',
|
||||
kanban_board_default_workdir_placeholder: 'Chemin du workspace (facultatif)',
|
||||
kanban_board_name_required: 'Le nom est requis',
|
||||
kanban_board_slug_required: 'Le slug est requis',
|
||||
kanban_card_complete: 'terminer',
|
||||
@@ -18262,6 +18435,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Afficher les sorties des tâches cron comme des conversations dans la barre latérale. Uniquement actif lorsque les sessions non-WebUI sont activées. Désactivé par défaut ; les tâches à haute fréquence peuvent inonder la barre latérale.',
|
||||
settings_desc_webhook_sessions: 'Afficher les exécutions webhook comme des conversations dans la barre latérale. Uniquement actif lorsque les sessions non-WebUI sont activées. Désactivé par défaut ; les routes à volume élevé peuvent inonder la barre latérale.',
|
||||
settings_desc_kanban_sessions: 'Afficher les exécutions des workers kanban comme des conversations dans la barre latérale. Uniquement actif lorsque les sessions non-WebUI sont activées. Désactivé par défaut ; les exécutions de workers sont internes et peuvent inonder la barre latérale.',
|
||||
settings_desc_previous_messaging_sessions: 'Afficher les anciennes sessions Discord, Telegram, Slack et Weixin qui ont été remplacées par une réinitialisation ou une compression.',
|
||||
settings_desc_sync_insights: 'Replique l\'utilisation des jetons de l\'interface Web vers state.db afin que hermes /insights inclue les données des sessions du navigateur. Désactivé par défaut.',
|
||||
settings_desc_check_updates: 'Afficher une bannière lorsque de nouvelles versions de l\'interface Web ou de l\'agent sont disponibles. Exécute périodiquement un git fetch en arrière-plan.',
|
||||
@@ -18602,6 +18776,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Faites glisser pour réorganiser',
|
||||
workspace_reorder_failed: 'Échec de la réorganisation',
|
||||
workspace_added: 'Espace de travail ajouté',
|
||||
workspace_recovered_notice: (path) => `L’espace de travail supprimé de cette session a été récupéré dans ${path}.`,
|
||||
workspace_renamed: 'Espace de travail renommé',
|
||||
workspace_remove_confirm_title: 'Supprimer l\'espace de travail',
|
||||
workspace_remove_confirm_message: (path) => `Supprimer "${path}" ?`,
|
||||
@@ -19134,6 +19309,8 @@ const LOCALES = {
|
||||
kanban_board: 'Deska',
|
||||
kanban_board_archived: 'Deska archivována',
|
||||
kanban_board_color: 'Barva (volitelná)',
|
||||
kanban_board_default_workdir: 'Výchozí cesta workspace',
|
||||
kanban_board_default_workdir_placeholder: 'Cesta workspace (volitelná)',
|
||||
kanban_board_description: 'Popis (volitelný)',
|
||||
kanban_board_icon: 'Ikona (emoji, volitelná)',
|
||||
kanban_board_name: 'Název',
|
||||
@@ -19188,7 +19365,8 @@ const LOCALES = {
|
||||
kanban_ready_needs_assignee: 'Zvolili jste Nepřiřazeno + Připraveno. Dispečer tento úkol přeskočí. Odešlete znovu pro potvrzení nebo zvolte profil.',
|
||||
kanban_refresh: 'Obnovit',
|
||||
kanban_remove_dependency: 'Odebrat závislost',
|
||||
kanban_rename_board: 'Přejmenovat aktuální desku…',
|
||||
kanban_rename_board: 'Nastavení desky…',
|
||||
kanban_board_settings: 'Nastavení desky',
|
||||
kanban_run_dispatcher: 'Spustit dispečera',
|
||||
kanban_run_dispatcher_confirm: 'Toto získá úkoly ve stavu Připraveno na této desce a vytvoří pracovní dílčí procesy (jeden na úkol, až 8 na kliknutí). Pokračovat?',
|
||||
kanban_runs_count: 'Spuštění ({0})',
|
||||
@@ -19727,6 +19905,7 @@ const LOCALES = {
|
||||
settings_desc_virtualize_transcript: 'Experimentální. Vykresluje pouze viditelnou část velmi dlouhých chat transcriptů (přes 80 zpráv) pro urychlení posouvání. Standardně vypnuto protože to může způsobit blikání při posouvání na dlouhých relacích s velkými bloky volání nástrojů. Zapněte to pro vyzkoušení; nechte vypnuté pro vykreslení celého transcriptu najednou (a tak vyhledávání prohlížeče (Ctrl+F) odpovídá celé konverzaci).',
|
||||
settings_desc_voice_mode: 'Zobrazit tlačítko hlasového režimu (audio vlna) vedle mikrofonu pro diktování. Umožňuje mluvit přirozeně — Hermes automaticky odesílá po pauze a čte odpovědi nahlas. Vyžaduje prohlížeč který podporuje rozpoznávání řeči i TTS.',
|
||||
settings_desc_webhook_sessions: 'Zobrazit webhook běhy jako konverzace v bočním panelu. Aktivní pouze když jsou povoleny externí relace. Standardně vypnuto; vysokoobjemové trasy mohou zaplavit boční panel.',
|
||||
settings_desc_kanban_sessions: 'Zobrazit běhy kanban workerů jako konverzace v bočním panelu. Aktivní pouze když jsou povoleny externí relace. Standardně vypnuto; běhy workerů jsou interní a mohou zaplavit boční panel.',
|
||||
settings_desc_whats_new_summary: 'Mění akci Co je nového z otevření raw diff první na generování krátkého lidsky čitelného souhrnu. Regulární srovnání diff zůstává dostupné po souhrnu.',
|
||||
settings_desc_worklog_details_expanded_default: 'Když povoleno, detaily Worklog se začínají rozbalené tak aby byly viditelné nástroje, přemýšlení a karty pokroku bez dalšího kliknutí. Když vypnuto, detaily zůstávají sbalené standardně; manuální volby sbalení/rozbalení na úroveň tahu stále vyhrávají.',
|
||||
settings_desc_workspace_panel_open: 'Když povoleno, panel pracovního prostoru / prohlížeč souborů se otevírá automaticky s každou novou relací. Stále ho můžete kdykoliv manuálně zavřít.',
|
||||
@@ -19809,6 +19988,7 @@ const LOCALES = {
|
||||
settings_label_virtualize_transcript: 'Virtualizovat dlouhé transcripty (experimentální)',
|
||||
settings_label_voice_mode: 'Tlačítko hands-free hlasového režimu',
|
||||
settings_label_webhook_sessions: 'Zobrazit webhook relace',
|
||||
settings_label_kanban_sessions: 'Zobrazit kanban relace',
|
||||
settings_label_whats_new_summary: 'Shrnout Co je nového s AI',
|
||||
settings_label_worklog_details_expanded_default: 'Automaticky otevírat detaily Worklog',
|
||||
settings_label_workspace_panel_open: 'Ve výchozím stavu otevřít panel pracovního prostoru',
|
||||
@@ -19943,6 +20123,7 @@ const LOCALES = {
|
||||
worklog_thinking: 'Přemýšlím',
|
||||
workspace_add_path_placeholder: 'Přidat cestu pracovního prostoru (např. /home/user/my-project)',
|
||||
workspace_added: 'Pracovní prostor přidán',
|
||||
workspace_recovered_notice: (path) => `Odstraněný pracovní prostor této relace byl obnoven do ${path}.`,
|
||||
workspace_already_saved: 'Pracovní prostor již uložen — zvolte ho ze seznamu',
|
||||
workspace_auto_create_folder: 'Vytvořit složku pokud neexistuje',
|
||||
workspace_busy_switch: 'Nelze přepnout pracovní prostor, zatímco agent běží',
|
||||
@@ -20000,6 +20181,7 @@ const LOCALES = {
|
||||
bg_complete: 'Úkol na pozadí dokončen',
|
||||
bg_failed: 'Úkol na pozadí se nezdařil:',
|
||||
process_wakeup_label: 'Probuzení na pozadí',
|
||||
process_wakeup_matched: 'Sledovaný vzor nalezen',
|
||||
bg_label: 'Výsledek na pozadí:',
|
||||
bg_no_answer: 'Žádná odpověď',
|
||||
bg_running: 'Běží na pozadí...',
|
||||
@@ -20301,6 +20483,9 @@ const LOCALES = {
|
||||
selected_text_reply: 'Odpovědět výběrem',
|
||||
selected_text_reply_appended: 'Vybraný text přidán do editoru zprávy',
|
||||
selected_text_reply_title: 'Připojit vybraný text chatu jako citovaný kontext',
|
||||
selected_text_refine: 'Upravit',
|
||||
selected_text_refine_title: 'Zahájit upravitelný návrh úpravy z výběru',
|
||||
selected_text_refine_instruction: 'Pokyn k úpravě:',
|
||||
session_attention_approval: (n) => n === 1 ? 'Schválení' : `${n} schválení`,
|
||||
session_attention_approval_title: 'Čekání na rozhodnutí o povolení',
|
||||
session_attention_clarify: (n) => n === 1 ? 'Otázka' : `${n} ${_i18nCsPlural(n,'otázka','otázky','otázek')}`,
|
||||
@@ -20471,6 +20656,12 @@ const LOCALES = {
|
||||
workspace_new_worktree_conversation_meta: 'Vytvořit izolovaný git worktree pro tento workspace.',
|
||||
workspace_options: 'Možnosti pracovního prostoru',
|
||||
workspace_show_hidden_files: 'Zobrazit skryté soubory',
|
||||
workspace_sort_by: 'Seřadit podle',
|
||||
workspace_sort_name_asc: 'Název (A → Z)',
|
||||
workspace_sort_name_desc: 'Název (Z → A)',
|
||||
workspace_sort_created_desc: 'Datum vytvoření (nejnovější první)',
|
||||
workspace_sort_modified_desc: 'Datum úpravy (nejnovější první)',
|
||||
workspace_sort_created_unavailable: 'Čas vytvoření tento server nebo platforma neuvádí.',
|
||||
workspace_show_hidden_files_desc: 'Do stromu souborů zahrňte soubory .DS_Store, .git, node_modules a další skryté / systémové soubory.',
|
||||
workspace_switch_failed: 'Přepnutí workspace selhalo: ',
|
||||
workspace_usage: 'Použití: /workspace <jméno>',
|
||||
@@ -20536,6 +20727,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopyalama başarısız oldu',
|
||||
selected_text_reply: 'Seçimle yanıtla',
|
||||
selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle',
|
||||
selected_text_refine: 'İyileştir',
|
||||
selected_text_refine_title: 'Seçimden düzenlenebilir bir iyileştirme taslağı başlat',
|
||||
selected_text_refine_instruction: 'İyileştirme talimatı:',
|
||||
selected_text_reply_appended: 'Seçilen metin besteciye eklendi',
|
||||
saved_prompts_empty: 'Henüz kaydedilmiş istem yok.',
|
||||
saved_prompts_save_current: 'Mevcut girişi istem olarak kaydet',
|
||||
@@ -20849,6 +21043,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(cevap yok)',
|
||||
bg_failed: 'Arka plan görevi başarısız oldu:',
|
||||
process_wakeup_label: 'Arka plan uyandırması',
|
||||
process_wakeup_matched: 'İzleme deseni eşleşti',
|
||||
undo_exchange: 'Son değişimi geri al',
|
||||
cmd_status: 'Oturum bilgilerini göster',
|
||||
cmd_voice: 'Mikrofon girişini değiştir',
|
||||
@@ -20921,6 +21116,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Çalışma alanı seçilmedi. Dosyalara göz atmak için Ayarlar \u2192 Çalışma Alanı\'nda bir çalışma alanı ayarlayın.',
|
||||
workspace_empty_dir: 'Bu çalışma alanı boş.',
|
||||
workspace_show_hidden_files: 'Gizli dosyaları göster',
|
||||
workspace_sort_by: 'Sırala',
|
||||
workspace_sort_name_asc: 'Ad (A → Z)',
|
||||
workspace_sort_name_desc: 'Ad (Z → A)',
|
||||
workspace_sort_created_desc: 'Oluşturulma tarihi (en yeniler önce)',
|
||||
workspace_sort_modified_desc: 'Değiştirilme tarihi (en yeniler önce)',
|
||||
workspace_sort_created_unavailable: 'Oluşturma zamanı bu sunucu veya platform tarafından bildirilmiyor.',
|
||||
workspace_show_hidden_files_desc: 'Dosya ağacına .DS_Store, .git, node_modules ve diğer gizli / sistem dosyalarını ekleyin.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -21353,6 +21554,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Show cron sessions',
|
||||
settings_label_webhook_sessions: 'Show webhook sessions',
|
||||
settings_label_kanban_sessions: 'Kanban oturumlarını göster',
|
||||
settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster',
|
||||
settings_label_sync_insights: 'Analizlerle senkronize edin',
|
||||
settings_label_check_updates: 'Güncellemeleri kontrol edin',
|
||||
@@ -21497,7 +21699,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Panoya geri dön',
|
||||
kanban_lanes_by_profile: 'Profile göre şeritler',
|
||||
kanban_new_board: 'Yeni yönetim kurulu\u2026',
|
||||
kanban_rename_board: 'Mevcut panoyu yeniden adlandır\u2026',
|
||||
kanban_rename_board: 'Pano ayarları\u2026',
|
||||
kanban_board_settings: 'Pano ayarları',
|
||||
kanban_archive_board: 'Mevcut panoyu arşivle\u2026',
|
||||
kanban_archive_board_confirm: 'Arşiv panosu "{name}"? Görevler diskte kalır ve pano kanban/boards/_archived/ adresinden geri yüklenebilir.',
|
||||
kanban_board_archived: 'Pano arşivlendi',
|
||||
@@ -21506,6 +21709,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Açıklama (isteğe bağlı)',
|
||||
kanban_board_icon: 'Simge (emoji, isteğe bağlı)',
|
||||
kanban_board_color: 'Renk (isteğe bağlı)',
|
||||
kanban_board_default_workdir: 'Varsayılan workspace yolu',
|
||||
kanban_board_default_workdir_placeholder: 'Workspace yolu (isteğe bağlı)',
|
||||
kanban_board_name_required: 'Ad gerekli',
|
||||
kanban_board_slug_required: 'Slug gerekli',
|
||||
kanban_card_complete: 'tamamlamak',
|
||||
@@ -21606,6 +21811,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Surface cron job output as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-frequency jobs can flood the sidebar.',
|
||||
settings_desc_webhook_sessions: 'Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.',
|
||||
settings_desc_kanban_sessions: 'Kanban worker çalıştırmalarını kenar çubuğunda konuşmalar olarak göster. Yalnızca WebUI dışı oturumlar etkinleştirildiğinde aktif. Varsayılan olarak kapalı; worker çalıştırmaları dahili olup kenar çubuğunu doldurabilir.',
|
||||
settings_desc_previous_messaging_sessions: 'Sıfırlama veya sıkıştırmayla değiştirilen eski Discord, Telegram, Slack ve Weixin oturumlarını gösterin.',
|
||||
settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.',
|
||||
settings_desc_check_updates: 'WebUI veya Agent\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.',
|
||||
@@ -21950,6 +22156,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Yeniden sıralamak için sürükleyin',
|
||||
workspace_reorder_failed: 'Yeniden sıralama başarısız oldu',
|
||||
workspace_added: 'Çalışma alanı eklendi',
|
||||
workspace_recovered_notice: (path) => `Bu oturumun silinen çalışma alanı ${path} konumuna kurtarıldı.`,
|
||||
workspace_renamed: 'Çalışma alanı yeniden adlandırıldı',
|
||||
workspace_remove_confirm_title: 'Çalışma alanını kaldır',
|
||||
workspace_remove_confirm_message: (path) => `"${path}" kaldırılsın mı?`,
|
||||
@@ -22272,6 +22479,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopiowanie nie powiodło się',
|
||||
selected_text_reply: 'Odpowiedz z zaznaczeniem',
|
||||
selected_text_reply_title: 'Dodaj zaznaczony tekst czatu jako cytowany kontekst',
|
||||
selected_text_refine: 'Dopracuj',
|
||||
selected_text_refine_title: 'Rozpocznij edytowalny szkic dopracowania na podstawie zaznaczenia',
|
||||
selected_text_refine_instruction: 'Instrukcja dopracowania:',
|
||||
selected_text_reply_appended: 'Dodano zaznaczony tekst do pola wprowadzania',
|
||||
saved_prompts_empty: 'Brak zapisanych promptów.',
|
||||
saved_prompts_save_current: 'Zapisz bieżący tekst jako prompt',
|
||||
@@ -22595,6 +22805,7 @@ const LOCALES = {
|
||||
bg_no_answer: '(brak odpowiedzi)',
|
||||
bg_failed: 'Zadanie w tle nie powiodło się: ',
|
||||
process_wakeup_label: 'Wybudzenie w tle',
|
||||
process_wakeup_matched: 'Dopasowano wzorzec obserwacji',
|
||||
undo_exchange: 'Cofnij ostatnią wymianę',
|
||||
cmd_status: 'Pokaż informacje o sesji',
|
||||
cmd_voice: 'Przełącz wejście mikrofonu',
|
||||
@@ -22678,6 +22889,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Nie wybrano obszaru roboczego. Ustaw obszar roboczy w Ustawienia → Obszar roboczy, aby przeglądać pliki.',
|
||||
workspace_empty_dir: 'Ten obszar roboczy jest pusty.',
|
||||
workspace_show_hidden_files: 'Pokaż ukryte pliki',
|
||||
workspace_sort_by: 'Sortuj według',
|
||||
workspace_sort_name_asc: 'Nazwa (A → Z)',
|
||||
workspace_sort_name_desc: 'Nazwa (Z → A)',
|
||||
workspace_sort_created_desc: 'Data utworzenia (najnowsze pierwsze)',
|
||||
workspace_sort_modified_desc: 'Data modyfikacji (najnowsze pierwsze)',
|
||||
workspace_sort_created_unavailable: 'Czas utworzenia nie jest zgłaszany przez ten serwer lub platformę.',
|
||||
workspace_show_hidden_files_desc: 'Dołącz .DS_Store, .git, node_modules i inne ukryte/systemowe pliki w drzewie plików.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -23110,6 +23327,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Pokaż sesje cron',
|
||||
settings_label_webhook_sessions: 'Pokaż sesje webhooków',
|
||||
settings_label_kanban_sessions: 'Pokaż sesje kanban',
|
||||
settings_label_previous_messaging_sessions: 'Pokaż poprzednie sesje komunikatorów',
|
||||
settings_label_sync_insights: 'Synchronizuj z Insights',
|
||||
settings_label_check_updates: 'Sprawdzaj aktualizacje',
|
||||
@@ -23255,7 +23473,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Powrót do tablicy',
|
||||
kanban_lanes_by_profile: 'Tory według profili',
|
||||
kanban_new_board: 'Nowa tablica…',
|
||||
kanban_rename_board: 'Zmień nazwę bieżącej tablicy…',
|
||||
kanban_rename_board: 'Ustawienia tablicy…',
|
||||
kanban_board_settings: 'Ustawienia tablicy',
|
||||
kanban_archive_board: 'Zarchiwizuj bieżącą tablicę…',
|
||||
kanban_archive_board_confirm: 'Zarchiwizować tablicę "{name}"? Zadania pozostaną na dysku, a tablica może zostać przywrócona z kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Tablica zarchiwizowana',
|
||||
@@ -23264,6 +23483,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Opis (opcjonalnie)',
|
||||
kanban_board_icon: 'Ikona (emoji, opcjonalnie)',
|
||||
kanban_board_color: 'Kolor (opcjonalnie)',
|
||||
kanban_board_default_workdir: 'Domyślna ścieżka workspace',
|
||||
kanban_board_default_workdir_placeholder: 'Ścieżka workspace (opcjonalna)',
|
||||
kanban_board_name_required: 'Nazwa jest wymagana',
|
||||
kanban_board_slug_required: 'Identyfikator (slug) jest wymagany',
|
||||
kanban_card_complete: 'ukończone',
|
||||
@@ -23433,6 +23654,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Wyświetlaj wyjście zadań cron jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; zadania o wysokiej częstotliwości mogą zalać pasek boczny.',
|
||||
settings_desc_webhook_sessions: 'Wyświetlaj uruchomienia webhooków jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; trasy o dużym natężeniu mogą zalać pasek boczny.',
|
||||
settings_desc_kanban_sessions: 'Wyświetlaj uruchomienia workerów kanban jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; uruchomienia workerów są wewnętrzne i mogą zalać pasek boczny.',
|
||||
settings_desc_previous_messaging_sessions: 'Pokaż starsze sesje z Discorda, Telegrama, Slacka i Weixin, które zostały zastąpione przez reset lub kompresję.',
|
||||
settings_desc_sync_insights: 'Odzwierciedla zużycie tokenów WebUI w state.db, dzięki czemu hermes /insights uwzględnia dane sesji przeglądarki. Domyślnie wyłączone.',
|
||||
settings_desc_check_updates: 'Pokaż baner, gdy dostępne są nowsze wersje WebUI lub Agenta. Okresowo uruchamia pobieranie git fetch w tle.',
|
||||
@@ -23778,6 +24000,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Przeciągnij, aby zmienić kolejność',
|
||||
workspace_reorder_failed: 'Zmiana kolejności nie powiodła się',
|
||||
workspace_added: 'Dodano obszar roboczy',
|
||||
workspace_recovered_notice: (path) => `Usunięty obszar roboczy tej sesji został odzyskany w ${path}.`,
|
||||
workspace_renamed: 'Zmieniono nazwę obszaru roboczego',
|
||||
workspace_remove_confirm_title: 'Usuń obszar roboczy',
|
||||
workspace_remove_confirm_message: (path) => `Usunąć „${path}”?`,
|
||||
@@ -24008,6 +24231,9 @@ const LOCALES = {
|
||||
copy_failed: 'Sao chép thất bại',
|
||||
selected_text_reply: 'Trả lời với đoạn đã chọn',
|
||||
selected_text_reply_title: 'Thêm đoạn chat đã chọn làm ngữ cảnh trích dẫn',
|
||||
selected_text_refine: 'Tinh chỉnh',
|
||||
selected_text_refine_title: 'Bắt đầu bản nháp tinh chỉnh có thể chỉnh sửa từ đoạn đã chọn',
|
||||
selected_text_refine_instruction: 'Hướng dẫn tinh chỉnh:',
|
||||
selected_text_reply_appended: 'Đã thêm đoạn đã chọn vào ô nhập',
|
||||
saved_prompts_empty: 'Chưa có prompt đã lưu.',
|
||||
saved_prompts_save_current: 'Lưu nội dung hiện tại thành prompt',
|
||||
@@ -24299,6 +24525,7 @@ const LOCALES = {
|
||||
bg_no_answer:'(không có câu trả lời)',
|
||||
bg_failed:'Tác vụ nền thất bại: ',
|
||||
process_wakeup_label:'Đánh thức nền',
|
||||
process_wakeup_matched:'Đã khớp mẫu theo dõi',
|
||||
undo_exchange:'Hoàn tác lượt trao đổi cuối',
|
||||
cmd_status:'Hiển thị thông tin phiên',
|
||||
cmd_voice:'Bật/tắt nhập microphone',
|
||||
@@ -24382,6 +24609,12 @@ const LOCALES = {
|
||||
workspace_empty_no_path: 'Chưa chọn workspace. Đặt workspace trong Settings → Workspace để duyệt file.',
|
||||
workspace_empty_dir: 'Không gian làm việc này đang trống.',
|
||||
workspace_show_hidden_files: 'Hiện file ẩn',
|
||||
workspace_sort_by: 'Sắp xếp theo',
|
||||
workspace_sort_name_asc: 'Tên (A → Z)',
|
||||
workspace_sort_name_desc: 'Tên (Z → A)',
|
||||
workspace_sort_created_desc: 'Ngày tạo (mới nhất trước)',
|
||||
workspace_sort_modified_desc: 'Ngày sửa đổi (mới nhất trước)',
|
||||
workspace_sort_created_unavailable: 'Thời gian tạo không được báo cáo bởi máy chủ hoặc nền tảng này.',
|
||||
workspace_show_hidden_files_desc: 'Bao gồm .DS_Store, .git, node_modules và các file hệ thống / ẩn khác trong cây thư mục.',
|
||||
workspace_panel_show: 'Show workspace panel',
|
||||
workspace_panel_hide: 'Hide workspace panel',
|
||||
@@ -24686,6 +24919,7 @@ const LOCALES = {
|
||||
settings_label_claude_code_sessions: 'Show Claude Code sessions',
|
||||
settings_label_cron_sessions: 'Hiện phiên cron',
|
||||
settings_label_webhook_sessions: 'Hiện phiên webhook',
|
||||
settings_label_kanban_sessions: 'Hiện phiên kanban',
|
||||
settings_label_previous_messaging_sessions: 'Hiện các phiên nhắn tin trước đó',
|
||||
settings_label_sync_insights: 'Đồng bộ sang insights',
|
||||
settings_label_check_updates: 'Kiểm tra cập nhật',
|
||||
@@ -24834,7 +25068,8 @@ const LOCALES = {
|
||||
kanban_back_to_board: 'Quay lại bảng',
|
||||
kanban_lanes_by_profile: 'Cột theo hồ sơ',
|
||||
kanban_new_board: 'Bảng mới…',
|
||||
kanban_rename_board: 'Đổi tên bảng hiện tại…',
|
||||
kanban_rename_board: 'Cài đặt bảng…',
|
||||
kanban_board_settings: 'Cài đặt bảng',
|
||||
kanban_archive_board: 'Lưu trữ bảng hiện tại…',
|
||||
kanban_archive_board_confirm: 'Lưu trữ bảng "{name}"? Các tác vụ vẫn còn trên đĩa và bảng có thể được khôi phục từ kanban/boards/_archived/.',
|
||||
kanban_board_archived: 'Đã lưu trữ bảng',
|
||||
@@ -24843,6 +25078,8 @@ const LOCALES = {
|
||||
kanban_board_description: 'Mô tả (tùy chọn)',
|
||||
kanban_board_icon: 'Biểu tượng (emoji, tùy chọn)',
|
||||
kanban_board_color: 'Màu sắc (tùy chọn)',
|
||||
kanban_board_default_workdir: 'Đường dẫn workspace mặc định',
|
||||
kanban_board_default_workdir_placeholder: 'Đường dẫn workspace (tùy chọn)',
|
||||
kanban_board_name_required: 'Tên là bắt buộc',
|
||||
kanban_board_slug_required: 'Slug là bắt buộc',
|
||||
kanban_card_complete: 'hoàn tất',
|
||||
@@ -25006,6 +25243,7 @@ const LOCALES = {
|
||||
settings_desc_claude_code_sessions: 'Show Claude Code imported sessions in the sidebar, or hide them while leaving other external sessions visible.',
|
||||
settings_desc_cron_sessions: 'Hiển thị output của cron job như các cuộc trò chuyện trong sidebar. Chỉ hoạt động khi bật phiên không phải WebUI. Mặc định tắt; job tần suất cao có thể làm sidebar bị ngập.',
|
||||
settings_desc_webhook_sessions: 'Hiển thị các lần chạy webhook như các cuộc trò chuyện trong sidebar. Chỉ hoạt động khi bật phiên không phải WebUI. Mặc định tắt; route lưu lượng cao có thể làm sidebar bị ngập.',
|
||||
settings_desc_kanban_sessions: 'Hiển thị các lần chạy của kanban worker như các cuộc trò chuyện trong sidebar. Chỉ hoạt động khi bật phiên không phải WebUI. Mặc định tắt; các lần chạy worker là nội bộ và có thể làm sidebar bị ngập.',
|
||||
settings_desc_previous_messaging_sessions: 'Hiển thị các phiên Discord, Telegram, Slack và Weixin cũ đã bị thay thế bởi reset hoặc nén.',
|
||||
settings_desc_sync_insights: 'Đồng bộ token usage của WebUI sang state.db để hermes /insights bao gồm dữ liệu phiên trình duyệt. Mặc định tắt.',
|
||||
settings_desc_check_updates: 'Hiển thị banner khi có phiên bản mới của WebUI hoặc Agent. Định kỳ chạy git fetch nền.',
|
||||
@@ -25351,6 +25589,7 @@ const LOCALES = {
|
||||
workspace_drag_hint: 'Kéo để sắp xếp lại',
|
||||
workspace_reorder_failed: 'Sắp xếp lại thất bại',
|
||||
workspace_added: 'Đã thêm workspace',
|
||||
workspace_recovered_notice: (path) => `Workspace đã xóa của phiên này đã được khôi phục tại ${path}.`,
|
||||
workspace_renamed: 'Đã đổi tên workspace',
|
||||
workspace_remove_confirm_title: 'Xóa workspace',
|
||||
workspace_remove_confirm_message: (path) => `Xóa "${path}"?`,
|
||||
|
||||
+16
-3
@@ -17,7 +17,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Hermes">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
|
||||
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,'neon-soft':1,'neon-paint':1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<script>(function(){try{var _hadAppearance=localStorage.getItem('hermes-theme')!==null||localStorage.getItem('hermes-skin')!==null;var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,'neon-soft':1,'neon-paint':1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));if(_hadAppearance){localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);}if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<script>(function(){try{var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;}catch(e){}})()</script>
|
||||
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
|
||||
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
|
||||
@@ -279,7 +279,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-head-sub" style="padding:0 12px 8px">
|
||||
<select id="insightsPeriod" onchange="loadInsights()" style="width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
|
||||
<select id="insightsPeriod" class="insights-period-select" onchange="loadInsights()">
|
||||
<option value="7">7 days</option>
|
||||
<option value="30" selected>30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
@@ -1414,6 +1414,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_webhook_sessions">Surface webhook runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; high-volume routes can flood the sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field" id="settingsShowKanbanSessionsField" style="margin-left:24px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowKanbanSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_kanban_sessions">Show kanban sessions</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_kanban_sessions">Surface kanban worker runs as conversations in the sidebar. Only active when non-WebUI sessions are enabled. Defaults off; worker runs are internal and can flood the sidebar.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowPreviousMessagingSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
@@ -1718,7 +1725,7 @@
|
||||
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
|
||||
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="HTML preview"></iframe>
|
||||
</div>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:var(--font-mono);font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -1783,6 +1790,7 @@
|
||||
<h3 id="kanbanBoardModalTitle" data-i18n="kanban_new_board">New board</h3>
|
||||
<input type="hidden" id="kanbanBoardModalMode" value="create">
|
||||
<input type="hidden" id="kanbanBoardModalSlug" value="">
|
||||
<input type="hidden" id="kanbanBoardModalOriginalDefaultWorkdir" value="">
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanBoardModalName" data-i18n="kanban_board_name">Name</label>
|
||||
<input type="text" id="kanbanBoardModalName" maxlength="64" placeholder="e.g. Experiments" autocomplete="off">
|
||||
@@ -1795,6 +1803,11 @@
|
||||
<label for="kanbanBoardModalDesc" data-i18n="kanban_board_description">Description (optional)</label>
|
||||
<textarea id="kanbanBoardModalDesc" maxlength="200"></textarea>
|
||||
</div>
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanBoardModalDefaultWorkdir" data-i18n="kanban_board_default_workdir">Default workspace path</label>
|
||||
<input type="text" id="kanbanBoardModalDefaultWorkdir" list="kanbanBoardModalWorkdirs" maxlength="255" data-i18n-placeholder="kanban_board_default_workdir_placeholder" placeholder="Workspace path (optional)" autocomplete="off">
|
||||
<datalist id="kanbanBoardModalWorkdirs"></datalist>
|
||||
</div>
|
||||
<div class="kanban-modal-row-inline">
|
||||
<div class="kanban-modal-row">
|
||||
<label for="kanbanBoardModalIcon" data-i18n="kanban_board_icon">Icon (emoji, optional)</label>
|
||||
|
||||
+708
-115
File diff suppressed because it is too large
Load Diff
@@ -705,7 +705,7 @@ async function startCodexOAuth(){
|
||||
<p style="margin-top:8px"><strong>${t('oauth_codex_step2')}</strong></p>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;user-select:all">${esc(user_code)}</code>
|
||||
<button class="sm-btn" type="button" onclick="copyCodexOAuthCode('${esc(user_code)}')">Copy code</button>
|
||||
<button class="sm-btn" type="button" onclick="copyCodexOAuthCode(${jsArg(user_code)})">Copy code</button>
|
||||
<button class="sm-btn" type="button" onclick="cancelCodexOAuth()">Cancel</button>
|
||||
</div>
|
||||
<p style="margin-top:8px;color:var(--muted);font-size:13px">${t('oauth_codex_polling')}</p>
|
||||
|
||||
+434
-146
File diff suppressed because it is too large
Load Diff
+247
-188
@@ -150,18 +150,7 @@ function _profileMatchesActiveProfile(profile, activeProfile){
|
||||
const eventName = (typeof profile === 'string' && profile.trim()) ? profile.trim() : 'default';
|
||||
const activeName = (typeof activeProfile === 'string' && activeProfile.trim()) ? activeProfile.trim() : 'default';
|
||||
if(eventName === activeName) return true;
|
||||
// default/renamed-root equivalence — SYMMETRIC, mirroring the server's
|
||||
// _profiles_match (#5510 re-gate). Two shapes both resolve to the root:
|
||||
// (a) event names the literal 'default' alias + the active profile IS the root;
|
||||
// (b) event names the renamed root (roster is_default) + the active profile
|
||||
// reports the literal 'default' alias (S.activeProfileIsDefault).
|
||||
// Without (b), a valid same-profile project tagged with the renamed root name
|
||||
// was wrongly rejected when the active surface showed the 'default' alias.
|
||||
if(eventName === 'default' && !!S.activeProfileIsDefault) return true;
|
||||
if(!!S.activeProfileIsDefault
|
||||
&& typeof _cronProfileNameIsRootAlias === 'function'
|
||||
&& _cronProfileNameIsRootAlias(eventName)) return true;
|
||||
return false;
|
||||
return eventName === 'default' && !!S.activeProfileIsDefault;
|
||||
}
|
||||
|
||||
function _sessionEventProfilesMatch(eventProfile, activeProfile){
|
||||
@@ -200,6 +189,15 @@ function _clearRememberedNewChatDraftSession(sid) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _adoptRegenerationRevision(sessionPayload){
|
||||
if(!S||!S.session||!sessionPayload||typeof sessionPayload!=='object') return;
|
||||
if(Object.prototype.hasOwnProperty.call(sessionPayload,'regeneration_revision')){
|
||||
S.session.regeneration_revision=sessionPayload.regeneration_revision;
|
||||
}else{
|
||||
delete S.session.regeneration_revision;
|
||||
}
|
||||
}
|
||||
|
||||
async function _restoreRememberedNewChatDraftSession() {
|
||||
let sid = '';
|
||||
try { sid = localStorage.getItem(NEW_CHAT_DRAFT_SESSION_KEY) || ''; } catch (_) { sid = ''; }
|
||||
@@ -825,6 +823,7 @@ function _isSessionLocallyStreaming(s) {
|
||||
function _isSessionEffectivelyStreaming(s) {
|
||||
return Boolean(s && (
|
||||
s.is_streaming ||
|
||||
s.cron_running ||
|
||||
_hasPendingUserMessageSignal(s) ||
|
||||
_isSessionLocallyStreaming(s)
|
||||
));
|
||||
@@ -1049,6 +1048,7 @@ function _serverLiveSnapshotInflight(snapshot, uploaded){
|
||||
role:'assistant',
|
||||
content:lastAssistantText,
|
||||
reasoning:lastReasoningText||undefined,
|
||||
_ts:snapshot.last_message_ts??snapshot.lastMessageTs??undefined,
|
||||
_live:true,
|
||||
_journal_snapshot:true,
|
||||
});
|
||||
@@ -1224,8 +1224,12 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
const lastMessageAt = Number(s.last_message_at || 0);
|
||||
const hasServerRunSignal=Boolean(s.is_streaming||_hasPendingUserMessageSignal(s));
|
||||
const canMarkCompletedStream=Boolean(hasServerRunSignal||previousSnapshot||observedStreaming);
|
||||
const completedObservedStream = canMarkCompletedStream&&wasStreaming === true && !isStreaming;
|
||||
const completedWithNewMessages = Boolean(
|
||||
// #6728: cron liveness is server-side (only /api/crons/status exposes it);
|
||||
// the sidebar must defer its completion/unread transition while the job is
|
||||
// still running, or a mid-run message makes the row look completed.
|
||||
const cronRunning = Boolean(s.cron_running);
|
||||
const completedObservedStream = !cronRunning && canMarkCompletedStream && wasStreaming === true && !isStreaming;
|
||||
const completedWithNewMessages = !cronRunning && Boolean(
|
||||
(previousSnapshot || observedStreaming)
|
||||
&& !isStreaming
|
||||
&& (
|
||||
@@ -1233,7 +1237,7 @@ function _markPollingCompletionUnreadTransitions(sessions) {
|
||||
|| lastMessageAt > Number((previousSnapshot || observedStreaming).last_message_at || 0)
|
||||
)
|
||||
);
|
||||
const completedPersistedObservedStream = Boolean(observedStreaming && !isStreaming);
|
||||
const completedPersistedObservedStream = !cronRunning && Boolean(observedStreaming && !isStreaming);
|
||||
if (completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) {
|
||||
if (!_isSessionActivelyViewedForList(sid)) {
|
||||
// Tag cron session-list markers with source+profile so profile-switch
|
||||
@@ -1388,32 +1392,6 @@ function _setNewSessionPending(pending){
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveProjectForNewSession(options){
|
||||
let pid;
|
||||
if(Object.prototype.hasOwnProperty.call(options,'project_id')){
|
||||
pid=options.project_id;
|
||||
}else if(typeof _activeProject!=='undefined'&&_activeProject&&_activeProject!==NO_PROJECT_FILTER){
|
||||
pid=_activeProject;
|
||||
}
|
||||
if(!pid) return null;
|
||||
const _projects=typeof _allProjects!=='undefined'?_allProjects:[];
|
||||
return _projects.find(p=>p.project_id===pid)||null;
|
||||
}
|
||||
|
||||
async function _ensureProjectProfileForNewSession(project){
|
||||
const targetProfile=project&&typeof project.profile==='string'?project.profile.trim():'';
|
||||
if(!_showAllProfiles||!targetProfile) return false;
|
||||
const activeProfile=S.activeProfile||'default';
|
||||
if(_profileMatchesActiveProfile(targetProfile,activeProfile)) return true;
|
||||
if(typeof switchToProfile!=='function') return false;
|
||||
// Invocation-scoped ownership (not a page-global): pass the flag to THIS
|
||||
// switch call so an overlapping second switch can't inherit it (#5510 re-gate).
|
||||
try{
|
||||
await switchToProfile(targetProfile,{callerOwnsNewSession:true});
|
||||
}catch(_){}
|
||||
return _profileMatchesActiveProfile(targetProfile,S.activeProfile||'default');
|
||||
}
|
||||
|
||||
async function newSession(flash, options={}){
|
||||
if(_newSessionInFlight){
|
||||
if(typeof showToast==='function') showToast(_newSessionPendingText(),1500);
|
||||
@@ -1431,26 +1409,20 @@ async function newSession(flash, options={}){
|
||||
_messagesTruncated=false;
|
||||
_oldestIdx=0;
|
||||
clearLiveToolCards();
|
||||
const _newSessionProj=_resolveProjectForNewSession(options);
|
||||
if(_newSessionProj&&!await _ensureProjectProfileForNewSession(_newSessionProj)){
|
||||
const targetProfile=_newSessionProj&&typeof _newSessionProj.profile==='string'?_newSessionProj.profile.trim():'';
|
||||
if(targetProfile&&!_profileMatchesActiveProfile(targetProfile,S.activeProfile||'default')){
|
||||
throw new Error('Switch to '+targetProfile+' before opening a new chat in this project');
|
||||
}
|
||||
}
|
||||
const hasProjectTarget=Object.prototype.hasOwnProperty.call(options,'project_id')
|
||||
? !!options.project_id
|
||||
: !!(_activeProject&&_activeProject!==NO_PROJECT_FILTER);
|
||||
// Keep explicit workspace ownership on the server for project-targeted chats.
|
||||
// Explicit profile switch wins, then the current conversation, then the profile default.
|
||||
// Provenance lets the server recover only a deleted inherited path; explicit paths stay strict.
|
||||
const switchWs=S._profileSwitchWorkspace;
|
||||
S._profileSwitchWorkspace=null;
|
||||
const explicitWs=switchWs||null;
|
||||
const fallbackWs=explicitWs?null:((S._profileDefaultWorkspace||null)||(S.session?S.session.workspace:null));
|
||||
const reqBody={ profile:S.activeProfile||'default' };
|
||||
if(explicitWs) reqBody.workspace=explicitWs;
|
||||
else if(hasProjectTarget) reqBody.fallback_workspace=fallbackWs;
|
||||
else reqBody.workspace=fallbackWs;
|
||||
if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id;
|
||||
const sessionWs=(!switchWs&&S.session)?S.session.workspace:null;
|
||||
const inheritWs=switchWs||sessionWs||(S._profileDefaultWorkspace||null);
|
||||
const reqBody={
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
};
|
||||
if(S.session&&S.session.session_id){
|
||||
reqBody.prev_session_id=S.session.session_id;
|
||||
if(sessionWs) reqBody.workspace_inherited_from_prev_session=true;
|
||||
}
|
||||
// Three-value worktree contract (#6022): explicit true/false is forwarded
|
||||
// verbatim; an ABSENT key lets the server apply the agent's config-level
|
||||
// `worktree:` default. Auto-bind paths pass worktree:false explicitly so a
|
||||
@@ -1485,17 +1457,35 @@ async function newSession(flash, options={}){
|
||||
newModelState=_readPersistedModelState();
|
||||
}
|
||||
if(newModelState&&newModelState.model){
|
||||
// Keep #2518's provider fast path even when project-targeted chats let
|
||||
// the server pick project-default vs inherited workspace.
|
||||
reqBody.model=newModelState.model;
|
||||
// newModelState.model_provider wins; fallback order stays
|
||||
// window._activeProvider, then S.session&&S.session.model_provider, to
|
||||
// keep /api/session/new on the fast path from #2518.
|
||||
// Slash and @-qualified models keep model_provider null so the server's
|
||||
// slow-path repair still owns foreign-provider normalization.
|
||||
// Cold-start / picker-without-provider fallback (#2518): when the dropdown option's
|
||||
// data-provider is empty/'default' or the persisted state predates provider
|
||||
// tracking, newModelState.model_provider is null. POST /api/session/new's
|
||||
// fast path in _resolve_compatible_session_model_state requires both model
|
||||
// and a truthy model_provider; without it, the request falls into
|
||||
// get_available_models() and a 3-4s cold catalog rebuild. window._activeProvider
|
||||
// is hydrated at boot (ui.js) and on config refresh (panels.js), so it's a
|
||||
// safe default that matches the user's configured route. S.session.model_provider
|
||||
// is the previous-session fallback when the dropdown is unhydrated.
|
||||
//
|
||||
// Guard: a slash-qualified model (e.g. "gemini/gemini-2.5") or an
|
||||
// @provider:model string already carries a foreign provider namespace from
|
||||
// a previous session that was served by a different backend. Attaching
|
||||
// the current _activeProvider to such a slug would let the server's fast
|
||||
// path pass it through without consulting the catalog, silently
|
||||
// re-pointing the new session at the wrong backend (the very case the
|
||||
// slow-path normalization in _resolve_compatible_session_model_state is
|
||||
// designed to fix — see routes.py docstring around line 1891-1894). For
|
||||
// those models we leave the wire shape with model_provider=null so the
|
||||
// slow path's cross-provider repair still runs. Closes the open
|
||||
// follow-up from #2518.
|
||||
const _bareModel=!/[/]/.test(newModelState.model)&&!newModelState.model.startsWith('@');
|
||||
// #3410 follow-up: bare models whose family disagrees with the fallback
|
||||
// provider also stay null so the server repairs them.
|
||||
// Second guard (#3410-followup): even a bare model can carry a known
|
||||
// family prefix (gpt→openai, claude→anthropic, gemini→google). If that
|
||||
// family maps to a DIFFERENT provider than the fallback we'd attach, the
|
||||
// server fast path passes the pair through verbatim (no validation) and
|
||||
// silently routes to the wrong backend — so leave model_provider=null and
|
||||
// let the slow-path family repair run (mirrors routes.py _normalize_provider_id).
|
||||
const _fallbackProvider=_bareModel
|
||||
? ((usingConfiguredDefault?window._activeProvider:(window._activeProvider||(S.session&&S.session.model_provider)))||'')
|
||||
: '';
|
||||
@@ -1515,7 +1505,7 @@ async function newSession(flash, options={}){
|
||||
if(consumedExplicitModelOverride&&typeof _clearEmptyComposerModelOverride==='function'){
|
||||
_clearEmptyComposerModelOverride();
|
||||
}
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
S.session=data.session;if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);S.messages=data.session.messages||[];
|
||||
S._pendingSessionToolsets=null;
|
||||
if(_sessionSourceFilter==='cli') _sessionSourceFilter='webui';
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
@@ -1727,6 +1717,12 @@ async function loadSession(sid){
|
||||
// #2971: idempotent re-arm before the no-op guard revives a stream a prior
|
||||
// failed loadSession killed; no-ops on real switches.
|
||||
_rearmActiveSessionStream();
|
||||
// #6999: same-session force-reload coordination lives in the refresh paths
|
||||
// (refreshActiveSessionIfExternallyUpdated guard + session-updated SSE
|
||||
// handler in messages.js), NOT here: a second loadSession(sid,{force:true})
|
||||
// for the same sid is a legitimate supersede (generation bump below) that
|
||||
// cross-session ordering tests rely on. Coalescing at the entry point would
|
||||
// drop the superseding fetch and leave a stale first load in charge.
|
||||
if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)){
|
||||
// Re-selecting the already-open session is a no-op for transcript/scroll, but
|
||||
// it is still a *visit*: clear a stale sidebar unread dot (e.g. one a
|
||||
@@ -1758,6 +1754,13 @@ async function loadSession(sid){
|
||||
_yoloEnabled=false;_updateYoloPill();
|
||||
if(typeof stopClarifyPolling==='function') stopClarifyPolling();
|
||||
if(typeof hideClarifyCard==='function') hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed');
|
||||
// #6572: clear stale compression state when switching sessions.
|
||||
// The compression UI state is per-session and must not leak across loads.
|
||||
// Without this, a compression card from a prior session can appear as a
|
||||
// phantom "Compressing context" barrier on a fresh session that never
|
||||
// triggered compression.
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
else window._compressionUi=null;
|
||||
// Show loading indicator immediately for responsiveness.
|
||||
// Cleared by renderMessages() once full session data arrives.
|
||||
// Persist the current composer draft before switching away so it can be
|
||||
@@ -1985,6 +1988,7 @@ async function loadSession(sid){
|
||||
return loadSession(continuationSid,{...opts,skipLineageResolve:true,skipContinuationResolve:true,force:true,_preloadNotified:true});
|
||||
}
|
||||
S.session=data.session;
|
||||
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
|
||||
if(typeof _clearEmptyComposerModelOverride==='function') _clearEmptyComposerModelOverride();
|
||||
// Loading a real existing session abandons any pre-session toolset override
|
||||
// staged on the empty composer before any deferred refresh work runs.
|
||||
@@ -2063,17 +2067,8 @@ async function loadSession(sid){
|
||||
if(typeof startSessionStream==='function') startSessionStream(S.session.session_id);
|
||||
|
||||
|
||||
function _mergePendingSessionMessage(session,messages){
|
||||
if(!Array.isArray(messages)) return false;
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,messages):null;
|
||||
if(!pendingMsg) return false;
|
||||
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
|
||||
const currentTurnMessages=liveAssistantIdx>=0?messages.slice(0,liveAssistantIdx):messages;
|
||||
if(_hasCurrentTailUserDuplicate(currentTurnMessages,pendingMsg)) return false;
|
||||
if(liveAssistantIdx>=0) messages.splice(liveAssistantIdx,0,pendingMsg);
|
||||
else messages.push(pendingMsg);
|
||||
return true;
|
||||
}
|
||||
// _mergePendingSessionMessage is the global identity-aware helper shared by
|
||||
// loadSession and refreshSession; see its definition below.
|
||||
|
||||
// Phase 2a: If session is streaming, restore the persisted transcript first,
|
||||
// then merge the local INFLIGHT live tail. INFLIGHT is a recovery tail, not a
|
||||
@@ -2407,7 +2402,7 @@ async function loadSession(sid){
|
||||
);
|
||||
}
|
||||
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
if(typeof projectSessionArtifactsForOwner==='function') projectSessionArtifactsForOwner(sid);
|
||||
|
||||
// ── Cross-channel handoff hint ──
|
||||
// After session fully loaded, check if this is a messaging session with
|
||||
@@ -2429,7 +2424,7 @@ const _HANDOFF_THRESHOLD = 10; // conversation rounds
|
||||
const _HANDOFF_STORAGE_PREFIX = 'handoff:';
|
||||
const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at';
|
||||
const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at';
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback']);
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback', 'matrix', 'signal']);
|
||||
const _MESSAGING_SOURCE_LABELS = {
|
||||
weixin: 'WeChat',
|
||||
telegram: 'Telegram',
|
||||
@@ -2438,6 +2433,8 @@ const _MESSAGING_SOURCE_LABELS = {
|
||||
email: 'Email',
|
||||
wecom: 'WeCom',
|
||||
wecom_callback: 'WeCom Callback',
|
||||
matrix: 'Matrix',
|
||||
signal: 'Signal',
|
||||
};
|
||||
|
||||
function _isMessagingSession(session) {
|
||||
@@ -2810,7 +2807,7 @@ function _showHandoffHint(sid, rounds) {
|
||||
</div>
|
||||
<div class="handoff-hint-actions">
|
||||
<button class="handoff-hint-action" type="button">View summary</button>
|
||||
<button class="handoff-hint-dismiss" type="button" onclick="event.stopPropagation(); _dismissHandoffHint('${esc(sid)}')" title="Dismiss">
|
||||
<button class="handoff-hint-dismiss" type="button" onclick="event.stopPropagation(); _dismissHandoffHint(${jsArg(sid)})" title="Dismiss">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
@@ -2927,12 +2924,20 @@ async function _generateHandoffSummary(sid, rounds) {
|
||||
} catch (e) {
|
||||
console.warn('Handoff summary failed:', e);
|
||||
if (S.session && S.session.session_id === sid && typeof setHandoffUi === 'function') {
|
||||
// A 400 carries an actionable, user-fixable message (e.g. an ambiguous
|
||||
// custom-provider slug collision: rename one provider so its slug is
|
||||
// unique). Surface it verbatim rather than degrading to the generic
|
||||
// "try again" card — previously the server answered 200 with a warning
|
||||
// that this handler ignored, hiding the fix from the user.
|
||||
const errorText = (e && e.status === 400 && e.message)
|
||||
? e.message
|
||||
: ('Summary generation failed: ' + (e && e.message ? e.message : 'unknown error'));
|
||||
setHandoffUi({
|
||||
sessionId: sid,
|
||||
phase: 'error',
|
||||
channel,
|
||||
rounds,
|
||||
errorText: 'Summary generation failed: ' + e.message,
|
||||
errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3212,9 +3217,23 @@ async function _ensureMessagesLoaded(sid, opts) {
|
||||
// Expand render window to cover all loaded messages so the next
|
||||
// renderMessages() doesn't hide most of them behind a tiny window.
|
||||
if(typeof _messageRenderableMessageCount==='function'&&typeof _currentMessageRenderWindowSize==='function'){
|
||||
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(), _messageRenderableMessageCount());
|
||||
// #6999: bound the auto-expansion. This number gates
|
||||
// _messageHiddenBeforeCount() (load-older / jump-to-start affordances)
|
||||
// and the non-virtualized fallback render width; the virtualized DOM tail
|
||||
// is independently capped at MESSAGE_RENDER_WINDOW_DEFAULT via
|
||||
// _messageVirtualKeepTailCount(). Growing the window to the FULL loaded
|
||||
// transcript on every force reload (tab focus, SSE catch-up) zeroes the
|
||||
// hidden-before count for long sessions and widens the effective render
|
||||
// window for non-virtualized paths. Keep the #3686 intent (don't collapse
|
||||
// back to the 50-row default after a load) but cap the growth to a small
|
||||
// multiple of the default window.
|
||||
_messageRenderWindowSize=Math.max(
|
||||
_currentMessageRenderWindowSize(),
|
||||
Math.min(_messageRenderableMessageCount(), (typeof MESSAGE_RENDER_WINDOW_DEFAULT==='number'?MESSAGE_RENDER_WINDOW_DEFAULT:50)*4)
|
||||
);
|
||||
}
|
||||
if(S.session&&S.session.session_id===sid){
|
||||
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
|
||||
S.session.message_count=Number(data.session.message_count || msgs.length);
|
||||
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
|
||||
// Phase 2: the messages=1 response carries the canonical cold-load
|
||||
@@ -3328,6 +3347,32 @@ function _hasCurrentTailUserDuplicate(messages,candidate){
|
||||
return !!(existing&&_sameTranscriptMessage(existing,candidate));
|
||||
}
|
||||
|
||||
// Keep pending-user recovery ordering identical across load, reconnect, and
|
||||
// explicit refresh paths. The pending prompt owns the live assistant tail and
|
||||
// must be projected before it, regardless of which recovery response arrived.
|
||||
function _mergePendingSessionMessage(session,messages){
|
||||
if(!Array.isArray(messages)) return false;
|
||||
const liveAssistantIdx=messages.findIndex(m=>m&&m.role==='assistant'&&m._live);
|
||||
const currentTurnMessages=liveAssistantIdx>=0?messages.slice(0,liveAssistantIdx):messages;
|
||||
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(session,currentTurnMessages):null;
|
||||
if(!pendingMsg) return false;
|
||||
if(_hasCurrentTailUserDuplicate(currentTurnMessages,pendingMsg)) return false;
|
||||
if(liveAssistantIdx>=0){
|
||||
const misplacedIdx=messages.findIndex((m,idx)=>
|
||||
idx>liveAssistantIdx&&m&&m.role==='user'&&_sameTranscriptMessage(m,pendingMsg)
|
||||
);
|
||||
if(misplacedIdx>=0){
|
||||
const [misplacedUser]=messages.splice(misplacedIdx,1);
|
||||
messages.splice(liveAssistantIdx,0,misplacedUser);
|
||||
}else{
|
||||
messages.splice(liveAssistantIdx,0,pendingMsg);
|
||||
}
|
||||
}else{
|
||||
messages.push(pendingMsg);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _currentTurnAssistantText(messages){
|
||||
const list=Array.isArray(messages)?messages:[];
|
||||
let start=-1;
|
||||
@@ -3811,6 +3856,7 @@ async function _loadOlderMessages() {
|
||||
? virtualAddedHeight
|
||||
: Math.max(0, newScrollH - prevScrollH);
|
||||
_programmaticScroll = true;
|
||||
_programmaticScrollSetAt = performance.now();
|
||||
container.scrollTop = oldTop + addedHeight;
|
||||
requestAnimationFrame(()=>{ _programmaticScroll = false; });
|
||||
}
|
||||
@@ -3884,6 +3930,11 @@ async function _ensureAllMessagesLoaded() {
|
||||
_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);
|
||||
if (S.session && S.session.session_id === sid) {
|
||||
S.session.message_count = Number(data.session.message_count || msgs.length);
|
||||
if (Object.prototype.hasOwnProperty.call(data.session, 'regeneration_revision')) {
|
||||
S.session.regeneration_revision = data.session.regeneration_revision;
|
||||
} else {
|
||||
delete S.session.regeneration_revision;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_loadingOlder = false;
|
||||
@@ -4184,7 +4235,11 @@ function _sessionUrlForSid(sid){
|
||||
current.searchParams.delete('q');
|
||||
current.searchParams.delete('prompt');
|
||||
current.searchParams.delete('send');
|
||||
base.search=current.searchParams.toString();
|
||||
const retained=new URLSearchParams();
|
||||
current.searchParams.forEach((value,key)=>{
|
||||
if(key!=='action'||value!=='new-chat') retained.append(key,value);
|
||||
});
|
||||
base.search=retained.toString();
|
||||
base.hash=current.hash;
|
||||
}catch(_e){}
|
||||
return base.pathname+base.search+base.hash;
|
||||
@@ -4193,7 +4248,13 @@ function _setActiveSessionUrl(sid){
|
||||
if(typeof window==='undefined'||!window.history||!sid) return;
|
||||
const next=_sessionUrlForSid(sid);
|
||||
if(next && next!==(window.location.pathname+window.location.search+window.location.hash)){
|
||||
window.history.pushState({session_id:sid},'',next);
|
||||
let consumeLaunchAction=false;
|
||||
try{
|
||||
const current=new URL(window.location.href);
|
||||
consumeLaunchAction=current.searchParams.getAll('action').includes('new-chat');
|
||||
}catch(_e){}
|
||||
const method=consumeLaunchAction?'replaceState':'pushState';
|
||||
window.history[method]({session_id:sid},'',next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5718,6 +5779,12 @@ let _sessionTimeRefreshVisibilityHandler = null;
|
||||
let _activeSessionExternalRefreshTimer = null;
|
||||
let _activeSessionExternalRefreshInFlight = false;
|
||||
let _deferredActiveSessionExternalRefreshReason = '';
|
||||
// #6999 re-gate: per-SID latch of the maximum message_count announced by a
|
||||
// `session-updated` frame while the external-refresh guard was held. The
|
||||
// refresh owner runs ONE guarded follow-up in its finally instead of letting
|
||||
// the event die — production does not guarantee a second event, so discarding
|
||||
// would leave the transcript stale until the next focus/poll.
|
||||
let _pendingSessionUpdatedCounts = null;
|
||||
let _sessionEventsSSE = null;
|
||||
let _sessionEventsRefreshTimer = 0;
|
||||
let _sessionEventsRefreshPendingRequest = null;
|
||||
@@ -5810,6 +5877,51 @@ function _flushDeferredActiveSessionExternalRefresh(){
|
||||
void refreshActiveSessionIfExternallyUpdated(reason);
|
||||
}
|
||||
|
||||
// #6999 re-gate: coalesce, never discard. Called by the messages.js
|
||||
// `session-updated` handler when it finds the external-refresh guard held:
|
||||
// instead of dropping the update (production does not guarantee a second
|
||||
// event), latch the MAXIMUM announced count per SID. The refresh owner's
|
||||
// finally drains it with ONE guarded follow-up.
|
||||
function _latchSessionUpdatedPendingCount(sid, count){
|
||||
const n = Number(count);
|
||||
if(!sid || !Number.isFinite(n)) return;
|
||||
if(!_pendingSessionUpdatedCounts) _pendingSessionUpdatedCounts = {};
|
||||
const prev = _pendingSessionUpdatedCounts[sid];
|
||||
if(prev === undefined || n > prev) _pendingSessionUpdatedCounts[sid] = n;
|
||||
}
|
||||
|
||||
// Single-line entry used by the messages.js `session-updated` handler:
|
||||
// returns true when the external-refresh probe owns the guard (the frame was
|
||||
// latched for the owner's finally to drain); returns false when the handler
|
||||
// should take its normal direct-load path.
|
||||
function _coalesceSessionUpdatedWhileRefreshHeld(sid, count){
|
||||
if(typeof _activeSessionExternalRefreshInFlight === 'undefined' || !_activeSessionExternalRefreshInFlight) return false;
|
||||
_latchSessionUpdatedPendingCount(sid, count);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drain the per-SID latch after the external-refresh owner releases its
|
||||
// guard. Runs ONE guarded follow-up only when local state is still behind
|
||||
// the latched count — a same-SID load during the refresh window already
|
||||
// caught us up, a switch-away moved the active session elsewhere, and
|
||||
// duplicate events coalesce into this single follow-up.
|
||||
function _drainSessionUpdatedPendingCount(){
|
||||
const pending = _pendingSessionUpdatedCounts;
|
||||
_pendingSessionUpdatedCounts = null;
|
||||
if(!pending || !S.session || !S.session.session_id) return;
|
||||
const sid = S.session.session_id;
|
||||
const latched = pending[sid];
|
||||
if(latched === undefined) return;
|
||||
const localCount = Number(S.session.message_count || (Array.isArray(S.messages)?S.messages.length:0) || 0);
|
||||
if(Number.isFinite(localCount) && localCount >= latched) return;
|
||||
// The follow-up re-enters refreshActiveSessionIfExternallyUpdated, which
|
||||
// re-probes server metadata and only force-reloads when the count actually
|
||||
// changed — so the OOM guards (busy/stream/loading/document.hidden) all
|
||||
// apply, and a metadata-read-only probe that already ran during the refresh
|
||||
// window gets a second chance to observe the latched growth.
|
||||
void refreshActiveSessionIfExternallyUpdated('session-updated');
|
||||
}
|
||||
|
||||
// Reconcile the active session against server-side metadata. Returns a status
|
||||
// string so callers (notably the post-stream idle reconcile) can decide how to
|
||||
// react:
|
||||
@@ -5834,6 +5946,10 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
|
||||
if(_activeSessionExternalRefreshInFlight) return 'skipped';
|
||||
if(!S.session || !S.session.session_id) return 'skipped';
|
||||
if(S.busy || S.activeStreamId) return 'skipped';
|
||||
// #6999: if a load for this exact session is already in flight, it owns the
|
||||
// refresh — probing now would duplicate the fetch and the O(N) render work
|
||||
// that exhausts the tab's JS heap when the tab comes back into focus.
|
||||
if(_loadingSessionId === S.session.session_id) return 'skipped';
|
||||
if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){
|
||||
_deferActiveSessionExternalRefresh(reason||'poll');
|
||||
return 'skipped';
|
||||
@@ -5918,6 +6034,10 @@ async function refreshActiveSessionIfExternallyUpdated(reason){
|
||||
return 'failed';
|
||||
}finally{
|
||||
_activeSessionExternalRefreshInFlight = false;
|
||||
// #6999 re-gate: any `session-updated` frames that arrived while we owned
|
||||
// the guard were latched (coalesced), not dropped — run ONE guarded
|
||||
// follow-up when local state is still behind the latched count.
|
||||
_drainSessionUpdatedPendingCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7003,6 +7123,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
};
|
||||
const orphans=[];
|
||||
const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean));
|
||||
const childAttachOrderById=new Map();
|
||||
let childAttachCursor=0;
|
||||
const attachQueueById=new Map();
|
||||
for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){
|
||||
if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate);
|
||||
@@ -7012,6 +7134,12 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id));
|
||||
if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue;
|
||||
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
|
||||
const childRawRole=[
|
||||
child&&child.raw_source,
|
||||
child&&child.source_tag,
|
||||
child&&child.source,
|
||||
].map(source=>String(source||'').trim().toLowerCase()).find(Boolean)||'';
|
||||
const childIsDelegatedSubagent=_isChildSession(child)&&childRawRole==='subagent';
|
||||
const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id);
|
||||
const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable);
|
||||
if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue;
|
||||
@@ -7036,11 +7164,10 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
hiddenArchivedChildTree.add(child.session_id);
|
||||
continue;
|
||||
}
|
||||
// Cross-surface rows (for example a WebUI continuation from a Telegram
|
||||
// conversation) should remain top-level when there is no WebUI-owned parent
|
||||
// row to stack under. But if the parent is visible in this same sidebar
|
||||
// render, attach normally — delegated subagent rows are also cross-source
|
||||
// relative to their WebUI parent and should not be forced into orphans.
|
||||
// Independent cross-surface rows (for example a WebUI continuation from a
|
||||
// Telegram conversation) remain top-level instead of nesting under an
|
||||
// external parent. Delegated subagents are also cross-source, but they are
|
||||
// parent-owned work and should still attach to the visible parent row.
|
||||
const parentSourceMarker=String(parentRow&&(
|
||||
parentRow.session_source||parentRow.raw_source||parentRow.source_tag||parentRow.source
|
||||
)||'').toLowerCase();
|
||||
@@ -7051,12 +7178,15 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
parentRow.session_source==='messaging'||
|
||||
(parentSourceMarker&&parentSourceMarker!=='webui'&&parentSourceMarker!=='subagent'&&parentSourceMarker!=='other'&&parentSourceMarker!=='fork')
|
||||
);
|
||||
if(parentRow&&child._cross_surface_child_session&&parentIsExternal){
|
||||
if(parentRow&&child._cross_surface_child_session&&parentIsExternal&&!childIsDelegatedSubagent){
|
||||
if(childRenderable) orphans.push({...child,_orphan_child_session:true});
|
||||
continue;
|
||||
}
|
||||
if(parentRow){
|
||||
const childCopy={...child};
|
||||
if(!childAttachOrderById.has(childCopy.session_id)){
|
||||
childAttachOrderById.set(childCopy.session_id, childAttachCursor++);
|
||||
}
|
||||
if(parentSegment){
|
||||
childCopy._parent_segment_id=parentSegment.session_id;
|
||||
childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled';
|
||||
@@ -7085,6 +7215,23 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
orphans.push({...child,_orphan_child_session:true});
|
||||
}
|
||||
}
|
||||
const resolveReadOnlySession = typeof _isReadOnlySession === 'function'
|
||||
? _isReadOnlySession
|
||||
: ((session) => !!(session && session.read_only));
|
||||
for(const row of rows){
|
||||
if(Array.isArray(row._child_sessions)&&row._child_sessions.length>1){
|
||||
row._child_sessions.sort((a,b)=>{
|
||||
const readOnlyCmp = Number(resolveReadOnlySession(a))-Number(resolveReadOnlySession(b));
|
||||
if(readOnlyCmp!==0) return readOnlyCmp;
|
||||
const aOrder = childAttachOrderById.get(a&&a.session_id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = childAttachOrderById.get(b&&b.session_id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aOrder-bOrder;
|
||||
});
|
||||
}
|
||||
if(Array.isArray(row._child_sessions)){
|
||||
row._child_session_count=row._child_sessions.length;
|
||||
}
|
||||
}
|
||||
return [...rows,...orphans];
|
||||
}
|
||||
|
||||
@@ -7533,6 +7680,12 @@ function _attachProjectQuickCreateButton(chip, project){
|
||||
// project-assigned session appears deterministically.
|
||||
try{ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); }catch(_){}
|
||||
try{ if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false}); }catch(_){}
|
||||
// Mobile: the sidebar is a full-screen drawer over the main view — close
|
||||
// it after the project conversation is created so the user actually sees
|
||||
// the new session (mirrors $('btnNewChat').onclick in boot.js and the
|
||||
// #5409 close in _openSidebarSession). Failure path keeps the drawer open
|
||||
// so the toast stays visible for retry.
|
||||
if(typeof closeMobileSidebar==='function') closeMobileSidebar();
|
||||
}catch(err){
|
||||
_setActiveProjectFilter(previousProject);
|
||||
if(typeof showToast==='function') showToast('New conversation failed: '+(err&&err.message||err));
|
||||
@@ -8196,7 +8349,7 @@ function renderSessionListFromCache(){
|
||||
const childList=document.createElement('div');
|
||||
childList.className='session-child-sessions';
|
||||
['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation()));
|
||||
const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||
const sortedChildren=[...s._child_sessions];
|
||||
const openChildSession=async(childSession)=>{
|
||||
await _openSidebarSession(childSession, {skipLineageResolve:true});
|
||||
};
|
||||
@@ -9294,73 +9447,6 @@ function _startProjectRename(proj, chip){
|
||||
setTimeout(()=>{inp.focus();inp.select();},10);
|
||||
}
|
||||
|
||||
function _projectWorkspaceDisplayKey(path){
|
||||
return String(path||'').trim().replace(/\\/g,'/').replace(/\/+$/,'');
|
||||
}
|
||||
|
||||
function _showProjectDefaultWorkspacePicker(proj, wsList, anchorEvent){
|
||||
document.querySelectorAll('.project-ws-picker').forEach(el=>el.remove());
|
||||
const pick=document.createElement('div');
|
||||
pick.className='project-ws-picker';
|
||||
const vw=window.innerWidth||document.documentElement.clientWidth||0;
|
||||
const vh=window.innerHeight||document.documentElement.clientHeight||0;
|
||||
const x=Math.max(8,Math.min(anchorEvent.clientX||8,(vw||348)-348));
|
||||
const y=Math.max(8,Math.min(anchorEvent.clientY||8,(vh||316)-316));
|
||||
pick.style.left=x+'px';
|
||||
pick.style.top=y+'px';
|
||||
// label = display name (falls back to path); ws = the stored path value.
|
||||
const makeRow=(label,ws,title,isClear)=>{
|
||||
const row=document.createElement('div');
|
||||
row.className='pw-row'+(isClear?' pw-clear':'');
|
||||
const name=document.createElement('span');
|
||||
name.className='pw-label';
|
||||
name.textContent=label;
|
||||
if(title) name.title=title;
|
||||
row.appendChild(name);
|
||||
const isCurrent=!isClear&&_projectWorkspaceDisplayKey(ws)===_projectWorkspaceDisplayKey(proj.default_workspace);
|
||||
if(isCurrent){
|
||||
const check=document.createElement('span');
|
||||
check.className='pw-check';
|
||||
check.textContent='✓';
|
||||
row.appendChild(check);
|
||||
}
|
||||
row.onclick=async()=>{
|
||||
pick.remove();
|
||||
document.removeEventListener('click',dismiss);
|
||||
try{
|
||||
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:proj.color||null,default_workspace:ws||null})});
|
||||
await renderSessionList();
|
||||
showToast(ws?'Default workspace set':'Default workspace cleared');
|
||||
}catch(err){
|
||||
showToast('Failed: '+(err.message||err));
|
||||
}
|
||||
};
|
||||
return row;
|
||||
};
|
||||
if(proj.default_workspace){
|
||||
pick.appendChild(makeRow('Clear default',null,null,true));
|
||||
const sep=document.createElement('hr');
|
||||
sep.className='pw-sep';
|
||||
pick.appendChild(sep);
|
||||
}
|
||||
if(!wsList||!wsList.length){
|
||||
const none=document.createElement('div');
|
||||
none.className='pw-empty';
|
||||
none.textContent='No saved workspaces';
|
||||
pick.appendChild(none);
|
||||
}else{
|
||||
wsList.forEach(ws=>{
|
||||
const path=typeof ws==='string'?ws:(ws.path||ws.name||String(ws));
|
||||
const name=(ws&&typeof ws==='object'&&ws.name)?ws.name:path;
|
||||
pick.appendChild(makeRow(name,path,path,false));
|
||||
});
|
||||
}
|
||||
document.body.appendChild(pick);
|
||||
const dismiss=()=>{pick.remove();document.removeEventListener('click',dismiss);};
|
||||
setTimeout(()=>document.addEventListener('click',dismiss),0);
|
||||
}
|
||||
|
||||
|
||||
function _showProjectContextMenu(e, proj, chip){
|
||||
document.querySelectorAll('.project-ctx-menu').forEach(el=>el.remove());
|
||||
const menu=document.createElement('div');
|
||||
@@ -9403,33 +9489,6 @@ function _showProjectContextMenu(e, proj, chip){
|
||||
});
|
||||
menu.appendChild(colorRow);
|
||||
|
||||
// Default workspace
|
||||
const wsItem=document.createElement('div');
|
||||
wsItem.style.cssText='display:flex;align-items:center;gap:8px;padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||||
const wsLabel=document.createElement('span');
|
||||
wsLabel.style.cssText='flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||||
wsLabel.textContent='Default workspace';
|
||||
wsItem.appendChild(wsLabel);
|
||||
if(proj.default_workspace){
|
||||
wsItem.title=proj.default_workspace;
|
||||
const wsCheck=document.createElement('span');
|
||||
wsCheck.style.cssText='flex:0 0 auto;color:var(--accent,var(--text));';
|
||||
wsCheck.textContent='✓';
|
||||
wsItem.appendChild(wsCheck);
|
||||
}
|
||||
wsItem.onmouseenter=()=>wsItem.style.background='var(--hover-bg)';
|
||||
wsItem.onmouseleave=()=>wsItem.style.background='';
|
||||
wsItem.onclick=async(wsEv)=>{
|
||||
menu.remove();
|
||||
try{
|
||||
const data=await api('/api/workspaces',{method:'GET'});
|
||||
_showProjectDefaultWorkspacePicker(proj,(data&&data.workspaces)||[],wsEv||e);
|
||||
}catch(err){
|
||||
showToast('Failed to load workspaces: '+(err.message||err));
|
||||
}
|
||||
};
|
||||
menu.appendChild(wsItem);
|
||||
|
||||
// Divider + Delete
|
||||
const sep=document.createElement('hr');
|
||||
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<title>Shared Conversation - Hermes</title>
|
||||
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||
<meta name="referrer" content="same-origin">
|
||||
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<script>(function(){try{var themes={light:1,dark:1,system:1},skins={codex:1,terracotta:1,default:1,ares:1,mono:1,graphite:1,github:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1,neon:1,'neon-soft':1,'neon-paint':1,zeus:1,'verdigris':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark');var pendingExt=s&&s!=='default'&&!skins[s]&&!legacy[s];var skin=skins[s]?s:(pendingExt?s:(m?m[1]:'default'));var _hadAppearance=localStorage.getItem('hermes-theme')!==null||localStorage.getItem('hermes-skin')!==null;if(_hadAppearance){localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);}if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;}catch(e){document.documentElement.classList.add('dark');}})()</script>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
html,body{height:auto;min-height:100%}
|
||||
|
||||
+139
-75
@@ -1,4 +1,9 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
/* ── Typography contract ──
|
||||
--font-ui: shell chrome, controls, composer, labels, and ordinary UI.
|
||||
--font-conversation: user/assistant message prose (defaults to --font-ui).
|
||||
--font-mono: inline code, fenced code blocks, logs, tool/metadata strings, and terminal.
|
||||
*/
|
||||
/* ── Light mode (default) — warm gold-tinted palette from Hermes brand ── */
|
||||
:root {
|
||||
--bg:#FEFCF7;--sidebar:#FAF7F0;--border:#E0D8C8;--border2:rgba(0,0,0,0.15);
|
||||
@@ -15,10 +20,13 @@
|
||||
--message-body-font-size:14px;--message-body-line-height:1.75;--message-code-font-size:12.5px;--message-pre-code-font-size:13px;--message-table-font-size:12px;
|
||||
--file-tree-toggle-width:10px;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
|
||||
--font-conversation:var(--font-ui);
|
||||
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
--surface-subtle:rgba(0,0,0,.025);--surface-subtle-hover:rgba(0,0,0,.045);
|
||||
--border-subtle:rgba(0,0,0,.08);--border-muted:rgba(0,0,0,.12);
|
||||
font-family:var(--font-ui);font-size:14px;line-height:1.6;
|
||||
}
|
||||
button,input,select,textarea{font-family:var(--font-ui);}
|
||||
/* ── Font size modifiers ── */
|
||||
/* ── Font size preference: scale key UI text elements ── */
|
||||
/* Default is 14px (no attribute needed). Small=12px, Large=16px, Extra Large=18px. */
|
||||
@@ -141,7 +149,6 @@
|
||||
--user-bubble-bg:#EFEFEF;--user-bubble-border:#DEDEDE;--user-bubble-text:#242424;--user-bubble-placeholder:#707070;
|
||||
--user-selection-bg:rgba(0,0,0,0.16);--user-selection-text:#111111;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-family:var(--font-ui);
|
||||
}
|
||||
:root.dark[data-skin="graphite"]{
|
||||
@@ -203,7 +210,7 @@
|
||||
:root.dark[data-skin="graphite"] .composer-box{box-shadow:0 1px 2px rgba(0,0,0,0.28);}
|
||||
:root[data-skin="graphite"] .composer-box:focus-within{border-color:var(--accent)!important;box-shadow:0 0 0 2px var(--focus-ring)!important;}
|
||||
:root[data-skin="graphite"] textarea#msg{font-family:var(--font-ui)!important;font-size:14px;font-weight:430;letter-spacing:0;line-height:1.45;}
|
||||
:root[data-skin="graphite"] .msg-body{font-family:var(--font-ui);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="graphite"] .msg-body{font-family:var(--font-conversation);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="graphite"][data-font-size="small"] .msg-body{font-size:11.5px;line-height:1.6;}
|
||||
:root[data-skin="graphite"][data-font-size="large"] .msg-body{font-size:15px;line-height:1.62;}
|
||||
:root[data-skin="graphite"][data-font-size="xlarge"] .msg-body{font-size:17px;line-height:1.62;}
|
||||
@@ -251,7 +258,7 @@
|
||||
:root[data-skin="graphite"] .btn.primary:hover,
|
||||
:root[data-skin="graphite"] .update-primary:hover,
|
||||
:root[data-skin="graphite"] .clarify-submit:hover{background:var(--accent-hover)!important;border-color:var(--accent-hover)!important;}
|
||||
/* ── Skin: Codex (OpenAI terminal IDE) ──
|
||||
/* ── Skin: Codex (OpenAI terminal IDE) ──
|
||||
Full palette rewrite with minimal editor surfaces, crisp contrast,
|
||||
and restrained OpenAI green action chrome. Uses native system fonts so it stays local,
|
||||
fast, and license-safe while feeling closer to modern code workbenches. */
|
||||
@@ -268,7 +275,6 @@
|
||||
--user-bubble-bg:#EBEBEB;--user-bubble-border:#D9D9D9;--user-bubble-text:#252523;--user-bubble-placeholder:#6A6A68;
|
||||
--user-selection-bg:rgba(46,122,96,0.20);--user-selection-text:#0D0D0D;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-family:var(--font-ui);
|
||||
}
|
||||
:root.dark[data-skin="codex"]{
|
||||
@@ -330,7 +336,7 @@
|
||||
:root.dark[data-skin="codex"] .composer-box{box-shadow:0 1px 2px rgba(0,0,0,0.28);}
|
||||
:root[data-skin="codex"] .composer-box:focus-within{border-color:var(--accent)!important;box-shadow:0 0 0 2px var(--focus-ring)!important;}
|
||||
:root[data-skin="codex"] textarea#msg{font-family:var(--font-ui)!important;font-size:14px;font-weight:430;letter-spacing:0;line-height:1.45;}
|
||||
:root[data-skin="codex"] .msg-body{font-family:var(--font-ui);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="codex"] .msg-body{font-family:var(--font-conversation);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="codex"][data-font-size="small"] .msg-body{font-size:11.5px;line-height:1.6;}
|
||||
:root[data-skin="codex"][data-font-size="large"] .msg-body{font-size:15px;line-height:1.62;}
|
||||
:root[data-skin="codex"][data-font-size="xlarge"] .msg-body{font-size:17px;line-height:1.62;}
|
||||
@@ -395,7 +401,6 @@
|
||||
--user-bubble-bg:#F0EEE6;--user-bubble-border:#E2DED2;--user-bubble-text:#30302E;--user-bubble-placeholder:#87867F;
|
||||
--user-selection-bg:rgba(217,119,87,0.24);--user-selection-text:#141413;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-family:var(--font-ui);
|
||||
}
|
||||
:root.dark[data-skin="terracotta"]{
|
||||
@@ -457,7 +462,7 @@
|
||||
:root.dark[data-skin="terracotta"] .composer-box{box-shadow:0 1px 2px rgba(0,0,0,0.28);}
|
||||
:root[data-skin="terracotta"] .composer-box:focus-within{border-color:var(--accent)!important;box-shadow:0 0 0 2px var(--focus-ring)!important;}
|
||||
:root[data-skin="terracotta"] textarea#msg{font-family:var(--font-ui)!important;font-size:14px;font-weight:430;letter-spacing:0;line-height:1.45;}
|
||||
:root[data-skin="terracotta"] .msg-body{font-family:var(--font-ui);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="terracotta"] .msg-body{font-family:var(--font-conversation);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="terracotta"][data-font-size="small"] .msg-body{font-size:11.5px;line-height:1.6;}
|
||||
:root[data-skin="terracotta"][data-font-size="large"] .msg-body{font-size:15px;line-height:1.62;}
|
||||
:root[data-skin="terracotta"][data-font-size="xlarge"] .msg-body{font-size:17px;line-height:1.62;}
|
||||
@@ -522,7 +527,6 @@
|
||||
--user-bubble-bg:#EFEFEF;--user-bubble-border:#DEDEDE;--user-bubble-text:#242424;--user-bubble-placeholder:#707070;
|
||||
--user-selection-bg:rgba(0,0,0,0.16);--user-selection-text:#111111;
|
||||
--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
--font-mono:ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
||||
font-family:var(--font-ui);
|
||||
}
|
||||
:root.dark[data-skin="github"]{
|
||||
@@ -584,7 +588,7 @@
|
||||
:root.dark[data-skin="github"] .composer-box{box-shadow:0 1px 2px rgba(0,0,0,0.28);}
|
||||
:root[data-skin="github"] .composer-box:focus-within{border-color:var(--accent)!important;box-shadow:0 0 0 2px var(--focus-ring)!important;}
|
||||
:root[data-skin="github"] textarea#msg{font-family:var(--font-ui)!important;font-size:14px;font-weight:430;letter-spacing:0;line-height:1.45;}
|
||||
:root[data-skin="github"] .msg-body{font-family:var(--font-ui);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="github"] .msg-body{font-family:var(--font-conversation);font-size:13px;font-weight:430;letter-spacing:0;line-height:1.6;}
|
||||
:root[data-skin="github"][data-font-size="small"] .msg-body{font-size:11.5px;line-height:1.6;}
|
||||
:root[data-skin="github"][data-font-size="large"] .msg-body{font-size:15px;line-height:1.62;}
|
||||
:root[data-skin="github"][data-font-size="xlarge"] .msg-body{font-size:17px;line-height:1.62;}
|
||||
@@ -1551,7 +1555,7 @@
|
||||
.system-health-foot{font-size:11px;color:var(--muted);line-height:1.45;opacity:.82;}
|
||||
.app-titlebar-icon{display:inline-flex;align-items:center;color:var(--accent);}
|
||||
.app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;}
|
||||
.app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;}
|
||||
.app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:var(--font-mono);white-space:nowrap;flex-shrink:0;}
|
||||
.app-titlebar-sub[hidden]{display:none;}
|
||||
.app-titlebar-title-popover{position:fixed;z-index:300;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.18);word-break:break-word;line-height:1.4;max-width:calc(100vw - 16px);pointer-events:auto;}
|
||||
.app-titlebar-hamburger,.app-titlebar-spacer{display:none;width:44px;height:44px;flex-shrink:0;}
|
||||
@@ -1723,17 +1727,6 @@
|
||||
.session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;}
|
||||
.session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);}
|
||||
.session-action-menu.open{display:block;}
|
||||
/* Project default-workspace picker (#5510). Class-based so it inherits skin
|
||||
variables instead of hardcoding colors inline. */
|
||||
.project-ws-picker{position:fixed;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:200px;max-width:min(340px,calc(100vw - 16px));max-height:300px;overflow-y:auto;box-shadow:0 4px 16px rgba(0,0,0,.35);}
|
||||
.project-ws-picker .pw-row{display:flex;align-items:center;gap:8px;padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);}
|
||||
.project-ws-picker .pw-row:hover{background:var(--hover-bg);}
|
||||
.project-ws-picker .pw-row .pw-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.project-ws-picker .pw-row .pw-check{flex:0 0 auto;color:var(--accent,var(--text));}
|
||||
.project-ws-picker .pw-row.pw-clear{color:var(--muted);}
|
||||
.project-ws-picker .pw-sep{border:none;border-top:1px solid var(--border);margin:4px 0;}
|
||||
.project-ws-picker .pw-empty{padding:7px 14px;font-size:13px;color:var(--muted);}
|
||||
|
||||
.session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;}
|
||||
.session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:8px 14px;}
|
||||
.session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;}
|
||||
@@ -2002,7 +1995,7 @@
|
||||
.approval-card.collapsed .approval-header{margin-bottom:0;}
|
||||
.approval-card.collapsed .approval-desc,.approval-card.collapsed .approval-cmd,.approval-card.collapsed .approval-counter,.approval-card.collapsed .approval-btns{display:none;}
|
||||
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
|
||||
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:var(--font-mono);font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
|
||||
.approval-counter{font-size:11px;color:var(--muted);margin:0 0 12px;font-variant-numeric:tabular-nums;letter-spacing:0.01em;}
|
||||
.approval-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
|
||||
.approval-btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;border:1px solid var(--border2);background:var(--hover-bg);color:var(--text);cursor:pointer;transition:all .15s;white-space:nowrap;}
|
||||
@@ -2241,6 +2234,7 @@
|
||||
.notes-preview-card .memory-content{max-height:420px;overflow:auto;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:var(--input-bg);border:1px solid var(--border2);border-radius:8px;color:var(--text);padding:8px 28px 8px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
.insights-period-select{width:auto;border-radius:6px;padding:6px 28px 6px 10px;font-size:12px;margin-bottom:0;}
|
||||
select:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg);}
|
||||
optgroup{color:var(--muted);font-size:11px;font-weight:700;}
|
||||
option{background:var(--bg);color:var(--text);padding:6px;}
|
||||
@@ -2312,7 +2306,7 @@
|
||||
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
|
||||
.role-icon.user{background:var(--accent-bg);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
|
||||
.msg-body{font-size:var(--message-body-font-size);line-height:var(--message-body-line-height);color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body{font-family:var(--font-conversation);font-size:var(--message-body-font-size);line-height:var(--message-body-line-height);color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
|
||||
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
|
||||
.msg-body td p,.msg-body th p{margin:0;}
|
||||
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
|
||||
@@ -2328,7 +2322,7 @@
|
||||
.msg-body > h1:first-child,.msg-body > h2:first-child,.msg-body > h3:first-child,
|
||||
.msg-body > h4:first-child,.msg-body > h5:first-child,.msg-body > h6:first-child{margin-top:0;}
|
||||
.msg-body strong{color:var(--strong);font-weight:600;}.msg-body em{color:var(--em);font-style:italic;}
|
||||
.msg-body code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:var(--message-code-font-size);background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body code{font-family:var(--font-mono);font-size:var(--message-code-font-size);background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.msg-body pre{background:var(--code-bg);border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;contain:content;margin:10px 0;}
|
||||
.msg-body pre code{background:none;padding:0;border-radius:0;color:var(--pre-text);font-size:var(--message-pre-code-font-size);line-height:1.6;}
|
||||
.msg-body pre.md-source-block,.preview-md pre.md-source-block{white-space:pre-wrap;overflow-x:hidden;overflow-wrap:anywhere;}
|
||||
@@ -2345,6 +2339,33 @@
|
||||
.process-wakeup-row .msg-body pre.process-wakeup-text{margin:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--muted);font-family:var(--font-mono);font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;}
|
||||
.process-wakeup-row .msg-foot{padding-left:0;margin-top:5px;}
|
||||
@media(max-width:700px){.process-wakeup-notice{margin-left:0;}}
|
||||
/* #6345: collapsed summary-card variant for parseable wakeups. */
|
||||
.process-wakeup-notice.process-wakeup-fail{border-left-color:var(--error,#f85149);}
|
||||
.process-wakeup-card{margin:0;}
|
||||
/* Wrap rather than clip when the row can't fit one line: keeps the label,
|
||||
chip, and timestamp inside the card at narrow widths (#6350 review finding 3). */
|
||||
.process-wakeup-card>summary{list-style:none;cursor:pointer;display:flex;align-items:center;flex-wrap:wrap;gap:6px 8px;min-width:0;}
|
||||
.process-wakeup-card>summary::-webkit-details-marker{display:none;}
|
||||
.process-wakeup-toggle{display:inline-flex;flex:0 0 auto;color:var(--muted);transition:transform .15s ease;}
|
||||
.process-wakeup-card[open]>summary .process-wakeup-toggle{transform:rotate(90deg);}
|
||||
.process-wakeup-summary .process-wakeup-label{margin-bottom:0;flex:0 0 auto;}
|
||||
.process-wakeup-summary .msg-time{margin-left:auto;flex:0 0 auto;}
|
||||
.process-wakeup-cmd{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:11.5px;color:var(--text);background:transparent;padding:0;}
|
||||
.process-wakeup-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border-radius:999px;font-size:10.5px;font-weight:600;border:1px solid var(--border);color:var(--muted);flex:0 0 auto;white-space:nowrap;}
|
||||
.process-wakeup-chip svg{width:11px;height:11px;flex:0 0 auto;}
|
||||
.process-wakeup-chip.ok{color:var(--success,#3fb950);border-color:currentColor;}
|
||||
.process-wakeup-chip.fail{color:var(--error,#f85149);border-color:currentColor;}
|
||||
/* Watch chip must shrink so a long pattern truncates instead of pushing the
|
||||
row past the card edge on narrow viewports (#6350 review finding 3). */
|
||||
.process-wakeup-chip.watch{flex:0 1 auto;min-width:0;}
|
||||
.process-wakeup-chip.watch code{flex:0 1 auto;min-width:0;font-family:var(--font-mono);font-size:10.5px;background:transparent;padding:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.process-wakeup-detail{margin-top:8px;}
|
||||
.process-wakeup-cmd-row,.process-wakeup-pattern-row{margin-bottom:6px;display:flex;gap:6px;align-items:baseline;flex-wrap:wrap;}
|
||||
.process-wakeup-detail-key{font-size:10.5px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:var(--muted);flex:0 0 auto;}
|
||||
.process-wakeup-cmd-row code,.process-wakeup-pattern-row code{font-family:var(--font-mono);font-size:11.5px;color:var(--text);background:transparent;padding:0;overflow-wrap:anywhere;word-break:break-word;min-width:0;}
|
||||
.process-wakeup-detail pre.process-wakeup-text{max-height:320px;overflow:auto;}
|
||||
/* Guarantee a >=44px disclosure touch target under the mobile breakpoint (#6350 review finding 3). */
|
||||
@media(max-width:700px){.process-wakeup-card>summary{min-height:44px;}}
|
||||
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
|
||||
.msg-body pre[class*="language-"],.msg-body pre code[class*="language-"]{background:var(--code-bg) !important;}
|
||||
/* Fix #1463: Prism YAML grammar collapses newlines inside token spans — force pre */
|
||||
@@ -2369,7 +2390,7 @@
|
||||
.diff-inline-error{color:#ef4444;font-size:13px;padding:8px 12px;border:1px solid rgba(239,68,68,.2);border-radius:8px;margin:6px 0;}
|
||||
/* JSON/YAML tree viewer */
|
||||
.code-tree-wrap{position:relative;}
|
||||
.tree-view{padding:4px 0;font-family:'JetBrains Mono',monospace;font-size:13px;}
|
||||
.tree-view{padding:4px 0;font-family:var(--font-mono);font-size:13px;}
|
||||
.tree-hidden{display:none;}
|
||||
.tree-toggle-btn{background:none;border:1px solid var(--border);border-radius:4px;padding:1px 8px;font-size:10px;color:var(--muted);cursor:pointer;font-weight:600;}
|
||||
.tree-toggle-btn:hover{color:var(--text);border-color:var(--muted);}
|
||||
@@ -2396,7 +2417,7 @@
|
||||
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
|
||||
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
|
||||
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
|
||||
.markdown-table-filter{display:block;width:min(260px,100%);margin:8px 0 4px;padding:5px 8px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--text);font:inherit;font-size:12px;}
|
||||
.markdown-table-filter{display:block;width:min(260px,100%);margin:8px 0 4px;padding:5px 8px;border:1px solid var(--border2);border-radius:6px;background:var(--input-bg);color:var(--text);font:inherit;font-family:var(--font-ui);font-size:12px;}
|
||||
.markdown-table-filter:focus{outline:1px solid var(--accent);outline-offset:1px;}
|
||||
.markdown-table-sort{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;min-height:20px;padding:0;border:0;background:transparent;color:inherit;font:inherit;font-weight:inherit;text-align:left;cursor:pointer;}
|
||||
.markdown-table-sort:focus-visible{outline:1px solid var(--accent);outline-offset:2px;}
|
||||
@@ -2573,7 +2594,8 @@
|
||||
@media (hover: hover) {
|
||||
.attach-thumb:hover{filter:brightness(1.05);transform:scale(1.04);}
|
||||
}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;overflow-y:auto;field-sizing:content;font-family:inherit;}
|
||||
textarea#msg:placeholder-shown{field-sizing:fixed;}
|
||||
textarea#msg::placeholder{color:var(--muted);}
|
||||
.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;container-type:inline-size;container-name:composer-footer;}
|
||||
.composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}
|
||||
@@ -2802,7 +2824,7 @@
|
||||
.workspace-panel-heading.workspace-panel-heading--enabled{cursor:pointer;}
|
||||
.workspace-panel-heading.workspace-panel-heading--enabled:hover,
|
||||
.workspace-panel-heading.workspace-panel-heading--enabled:focus-visible{color:var(--text);background:var(--hover-bg);outline:none;}
|
||||
.git-badge{grid-column:1 / -1;grid-row:2;justify-self:start;font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;overflow:hidden;min-width:0;max-width:100%;}
|
||||
.git-badge{grid-column:1 / -1;grid-row:2;justify-self:start;font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;white-space:nowrap;font-family:var(--font-mono);overflow:hidden;min-width:0;max-width:100%;}
|
||||
.topbar-source-badge{display:inline-flex;align-items:center;margin-left:6px;padding:2px 7px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);font-size:10px;font-weight:700;letter-spacing:.02em;vertical-align:middle;}
|
||||
.git-badge.dirty{color:var(--accent-text);background:var(--accent-bg);}
|
||||
.panel-actions{grid-column:2;grid-row:1;display:flex;gap:4px;flex-shrink:0;margin-left:auto;}
|
||||
@@ -2861,6 +2883,13 @@
|
||||
.workspace-prefs-item .workspace-prefs-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
||||
.workspace-prefs-item .workspace-prefs-name{font-weight:500;}
|
||||
.workspace-prefs-item .workspace-prefs-meta{font-size:11px;color:var(--muted);line-height:1.35;opacity:.85;}
|
||||
.workspace-prefs-group{padding:8px 0 4px;}
|
||||
.workspace-prefs-grouplabel{padding:0 14px 4px;color:var(--muted);font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;}
|
||||
.workspace-prefs-item--radio{padding:6px 14px;}
|
||||
.workspace-prefs-item.is-disabled{opacity:.55;cursor:default;}
|
||||
.workspace-prefs-item.is-disabled:hover{background:none;}
|
||||
.workspace-prefs-item.is-disabled input{cursor:default;}
|
||||
.workspace-prefs-sep{height:1px;margin:4px 0;background:var(--border2);}
|
||||
.breadcrumb-seg{padding:1px 3px;border-radius:3px;}
|
||||
.breadcrumb-link{color:var(--muted);cursor:pointer;transition:color .12s;}
|
||||
.breadcrumb-link:hover{color:var(--text);background:var(--hover-bg);}
|
||||
@@ -2881,7 +2910,7 @@
|
||||
.preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;}
|
||||
.preview-area.visible{display:flex;opacity:1;}
|
||||
.preview-path{font-size:11px;color:var(--muted);padding-bottom:8px;border-bottom:1px solid var(--border);flex-shrink:0;}
|
||||
.preview-code{font-family:"SF Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--pre-text);}
|
||||
.preview-code{font-family:var(--font-mono);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word;color:var(--pre-text);}
|
||||
/* Workspace preview syntax highlighting (#3337): Prism's prism-tomorrow theme
|
||||
styles BOTH pre[class*="language-"] and code[class*="language-"] with its own
|
||||
gray background + padding. Prism propagates the language-* class onto the
|
||||
@@ -2909,7 +2938,7 @@
|
||||
.preview-md h5{font-size:14px;margin:16px 0 6px;text-transform:uppercase;letter-spacing:0.04em;}
|
||||
.preview-md h6{font-size:13px;margin:14px 0 6px;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;}
|
||||
.preview-md ul,.preview-md ol{margin:4px 0 10px 18px;}.preview-md li{margin-bottom:3px;}
|
||||
.preview-md code{font-family:"SF Mono",ui-monospace,monospace;font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.preview-md code{font-family:var(--font-mono);font-size:11.5px;background:var(--code-inline-bg);padding:1px 5px;border-radius:4px;color:var(--code-text);}
|
||||
.preview-md pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;}
|
||||
.preview-md pre code{background:none;padding:0;color:var(--pre-text);font-size:11.5px;line-height:1.55;}
|
||||
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
|
||||
@@ -3382,7 +3411,7 @@
|
||||
.cmd-item-name{font-size:13px;color:var(--text);font-weight:500;}
|
||||
.cmd-item-parent{color:var(--muted);font-weight:400;}
|
||||
.cmd-item-subarg{font-weight:600;}
|
||||
.cmd-item-path-value{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;}
|
||||
.cmd-item-path-value{font-family:var(--font-mono);font-size:12px;}
|
||||
.cmd-item-arg{color:var(--muted);font-weight:400;font-style:italic;}
|
||||
.cmd-item-desc{font-size:11px;color:var(--muted);margin-top:1px;}
|
||||
.cmd-item-badge{flex-shrink:0;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px;border:1px solid var(--border2);color:var(--muted);background:var(--hover-bg);}
|
||||
@@ -3407,10 +3436,13 @@
|
||||
.vscroll-measuring .msg-time{transition:none !important;}
|
||||
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
|
||||
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
|
||||
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;}
|
||||
.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);}
|
||||
.selected-text-reply-btn:hover{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
.selected-text-action-group{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--border2);border-radius:999px;background:var(--bg);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);visibility:hidden;opacity:0;pointer-events:none;transform:translateY(4px);transition:visibility 0s linear .12s,opacity .12s ease,transform .12s ease;user-select:none;}
|
||||
.selected-text-action-group.visible{visibility:visible;opacity:1;pointer-events:auto;transform:translateY(0);transition-delay:0s;}
|
||||
.selected-text-action-group button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:7px 11px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--text);font-family:inherit;font-size:12px;font-weight:700;line-height:1;cursor:pointer;white-space:nowrap;}
|
||||
.selected-text-action-group button:hover,.selected-text-action-group button:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-action-group button:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
@media (pointer:coarse){.selected-text-action-group button{min-width:44px;min-height:44px;}}
|
||||
@media (max-width:560px){.selected-text-action-group button{width:44px;min-width:44px;padding:7px;font-size:0;}.selected-text-action-group button::before{font-size:16px;}.selected-text-reply-btn::before{content:'↩';}.selected-text-refine-btn::before{content:'✦';}}
|
||||
/* TTS speaker button: hidden by default, shown when TTS is enabled.
|
||||
* Use body-class selector instead of JS inline-style so the rule survives
|
||||
* subsequent renderMd() passes and is not subject to inline-style cascade
|
||||
@@ -3421,7 +3453,7 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
|
||||
@keyframes tts-pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
||||
|
||||
/* ── Edit message inline ── */
|
||||
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid var(--accent-bg);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:inherit;line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px var(--accent-bg);margin-top:4px;}
|
||||
.msg-edit-area{width:100%;background:rgba(255,255,255,.05);border:1px solid var(--accent-bg);border-radius:8px;color:var(--text);padding:10px 12px;font-size:14px;font-family:var(--font-conversation);line-height:1.6;resize:none;outline:none;min-height:60px;box-sizing:border-box;box-shadow:0 0 0 3px var(--accent-bg);margin-top:4px;}
|
||||
.msg-edit-bar{display:flex;gap:8px;margin-top:8px;margin-bottom:4px;}
|
||||
.msg-edit-send{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s;}
|
||||
.msg-edit-send:hover{opacity:.85;}
|
||||
@@ -3702,6 +3734,18 @@ body.resizing .sidebar{transition:none!important;}
|
||||
border-radius:0;
|
||||
background:transparent;
|
||||
}
|
||||
/* #6454: on touch devices, grow the collapsed Worklog rail's tap target to clear the
|
||||
44px minimum without changing the desktop look. The summary is the tap surface that
|
||||
expands/collapses the settled Worklog, so it needs a comfortable touch height. */
|
||||
@media (hover:none) and (pointer:coarse){
|
||||
.tool-worklog-summary{padding-top:8px;padding-bottom:8px;min-height:44px;}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .tool-worklog-summary{
|
||||
padding-top:11px;
|
||||
padding-bottom:11px;
|
||||
min-height:44px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .tool-worklog-label{
|
||||
color:color-mix(in srgb,var(--muted) 74%,var(--bg));
|
||||
font-size:calc(var(--message-body-font-size) * .9);
|
||||
@@ -3714,7 +3758,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-worklog-group[data-tool-worklog-group="1"]:not([data-run-activity-group="1"]) .as-dot{
|
||||
display:none;
|
||||
}
|
||||
.tool-worklog-running-label{display:none;color:var(--accent-text);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;font-weight:600;line-height:1.4;}
|
||||
.tool-worklog-running-label{display:none;color:var(--accent-text);font-family:var(--font-mono);font-size:12px;font-weight:600;line-height:1.4;}
|
||||
.tool-worklog-group[data-tool-worklog-running="1"] .tool-worklog-running-label{display:inline-flex;}
|
||||
.tool-worklog-body{
|
||||
padding-left:0;
|
||||
@@ -3943,7 +3987,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-worklog-tool-group-label,
|
||||
.tg-sum{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tool-worklog-tool-group-running,
|
||||
.tg-run{display:none;color:var(--accent-text);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;font-weight:600;line-height:1.4;white-space:nowrap;}
|
||||
.tg-run{display:none;color:var(--accent-text);font-family:var(--font-mono);font-size:12px;font-weight:600;line-height:1.4;white-space:nowrap;}
|
||||
.tool-worklog-tool-group-body,
|
||||
.tool-group-body{overflow:hidden;max-height:0;transition:max-height .3s ease;}
|
||||
.tool-worklog-tool-group.open .tool-worklog-tool-group-body,
|
||||
@@ -4027,9 +4071,9 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.tool-card-detail-lead pre{margin:0;color:var(--text);font-family:var(--font-mono);font-size:var(--message-code-font-size);line-height:1.55;white-space:pre-wrap;word-break:break-word;}
|
||||
.tool-card-args{margin-bottom:6px;}
|
||||
.tool-card-args div{font-size:var(--message-code-font-size);line-height:1.6;}
|
||||
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--message-code-font-size);display:block;margin-bottom:2px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:var(--message-code-font-size);white-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;}
|
||||
.tool-card-result pre{font-size:var(--message-code-font-size);color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;width:100%;max-width:100%;box-sizing:border-box;min-width:0;}
|
||||
.tool-arg-key{color:var(--blue);font-family:var(--font-mono);font-size:var(--message-code-font-size);display:block;margin-bottom:2px;}
|
||||
.tool-arg-val{color:var(--muted);font-family:var(--font-mono);font-size:var(--message-code-font-size);white-space:pre-wrap;word-break:break-word;display:block;overflow-x:auto;}
|
||||
.tool-card-result pre{font-size:var(--message-code-font-size);color:var(--muted);font-family:var(--font-mono);white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;margin:0;line-height:1.55;width:100%;max-width:100%;box-sizing:border-box;min-width:0;}
|
||||
|
||||
/* ── Transparent Stream event rows ── */
|
||||
/* Inline rhythm: transparent stream events are transcript metadata inside an
|
||||
@@ -4595,7 +4639,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
color:var(--strong);
|
||||
}
|
||||
.handoff-summary-body code{
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
font-size:.92em;
|
||||
background:var(--code-inline-bg);
|
||||
color:var(--code-text);
|
||||
@@ -4650,7 +4694,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.compression-card-label{
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
letter-spacing:.03em;
|
||||
color:var(--muted);
|
||||
flex-shrink:0;
|
||||
@@ -4658,7 +4702,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.compression-card-command{
|
||||
font-size:11px;
|
||||
color:var(--text);
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
@@ -4696,7 +4740,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
}
|
||||
.compression-card-focus .compression-card-focus-label{
|
||||
color:var(--muted);
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
}
|
||||
.compression-card-error-body{
|
||||
color:#fecaca;
|
||||
@@ -4732,7 +4776,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.compression-reference-kind{
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
letter-spacing:.03em;
|
||||
flex-shrink:0;
|
||||
white-space:nowrap;
|
||||
@@ -4784,7 +4828,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
}
|
||||
.compression-reference-body pre{
|
||||
margin:0;
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
font-size:11px;
|
||||
line-height:1.55;
|
||||
white-space:pre-wrap;
|
||||
@@ -4822,7 +4866,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
.compression-complete-label{
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
letter-spacing:.03em;
|
||||
flex-shrink:0;
|
||||
}
|
||||
@@ -4833,7 +4877,7 @@ body.resizing .sidebar{transition:none!important;}
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:var(--text);
|
||||
font-family:'SF Mono',ui-monospace,monospace;
|
||||
font-family:var(--font-mono);
|
||||
font-size:11px;
|
||||
}
|
||||
.compression-complete-toggle{
|
||||
@@ -4941,7 +4985,7 @@ main.main > #mainPlugin{display:none;}
|
||||
.settings-section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:20px;padding-bottom:14px;border-bottom:1px solid var(--border);}
|
||||
.settings-section-title{font-size:18px;font-weight:600;letter-spacing:-.01em;color:var(--text);line-height:1.3;margin-bottom:4px;}
|
||||
.settings-section-meta{font-size:13px;color:var(--muted);line-height:1.55;}
|
||||
.settings-version-badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:600;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;flex-shrink:0;align-self:flex-start;letter-spacing:.02em;}
|
||||
.settings-version-badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:600;font-family:var(--font-mono);flex-shrink:0;align-self:flex-start;letter-spacing:.02em;}
|
||||
#checkUpdatesBlock{display:inline-flex;align-items:center;gap:0.5em;font-size:12px;flex-shrink:0;align-self:flex-start;flex-wrap:wrap;}
|
||||
#checkUpdatesBlock .btn-tiny{padding:4px 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:11px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:5px;transition:border-color .15s,color .15s;}
|
||||
#checkUpdatesBlock .btn-tiny:hover{border-color:var(--accent);color:var(--accent);}
|
||||
@@ -5354,7 +5398,7 @@ main.main > #mainPlugin{display:none;}
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
font-size:12px;
|
||||
font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
font-family:var(--font-mono);
|
||||
outline:none;
|
||||
transition:border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
@@ -5449,8 +5493,9 @@ main.main > #mainPlugin{display:none;}
|
||||
.extension-installed-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;}
|
||||
.extension-installed-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--muted);}
|
||||
.extension-installed-meta code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;}
|
||||
.extension-toggle-btn{white-space:nowrap;flex:0 0 auto;}
|
||||
.extension-toggle-btn:disabled{opacity:.55;cursor:not-allowed;}
|
||||
.extension-installed-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex:0 0 auto;flex-wrap:wrap;}
|
||||
.extension-toggle-btn,.extension-configure-btn{white-space:nowrap;flex:0 0 auto;}
|
||||
.extension-toggle-btn:disabled,.extension-configure-btn:disabled{opacity:.55;cursor:not-allowed;}
|
||||
.extension-settings-box{margin-top:8px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card-bg,var(--sidebar-bg));}
|
||||
.extension-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px;}
|
||||
.extension-settings-title{font-size:12px;font-weight:700;color:var(--text);}
|
||||
@@ -5523,7 +5568,8 @@ main.main > #mainPlugin{display:none;}
|
||||
.extensions-copy-diagnostics-btn{width:100%;}
|
||||
.extension-summary-grid{grid-template-columns:1fr;}
|
||||
.extension-installed-row{align-items:flex-start;flex-direction:column;}
|
||||
.extension-toggle-btn{width:100%;}
|
||||
.extension-installed-actions{width:100%;}
|
||||
.extension-installed-actions .extension-toggle-btn,.extension-installed-actions .extension-configure-btn{width:auto;flex:1 1 140px;}
|
||||
.extension-sidecar-row-head{align-items:flex-start;flex-direction:column;}
|
||||
.extension-sidecar-fields>div{flex-direction:column;gap:2px;}
|
||||
.extension-sidecar-fields span{flex:0 0 auto;}
|
||||
@@ -5541,7 +5587,7 @@ main.main > #mainPlugin{display:none;}
|
||||
.provider-card-model-tag{
|
||||
display:inline-block;
|
||||
padding:2px 8px;
|
||||
font-size:10.5px;font-family:ui-monospace,SFMono-Regular,\"SF Mono\",Menlo,Consolas,monospace;
|
||||
font-size:10.5px;font-family:var(--font-mono);
|
||||
color:var(--muted);
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
@@ -5612,7 +5658,7 @@ main.main > #mainPlugin{display:none;}
|
||||
.cron-script-badge{flex-shrink:0;font-size:12px;line-height:1;opacity:.92;}
|
||||
.cron-script-job-banner{margin-bottom:12px;}
|
||||
.cron-script-job-banner .detail-alert-title{font-weight:600;color:rgba(245,158,11,.98);}
|
||||
.detail-script{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;word-break:break-word;}
|
||||
.detail-script{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:var(--font-mono);word-break:break-word;}
|
||||
.detail-hint{font-size:11px;color:var(--muted);line-height:1.5;}
|
||||
.cron-script-card .detail-hint,.cron-script-card-hint{margin-top:8px;}
|
||||
.detail-badge.cron-mode-badge.script{background:rgba(148,163,184,.14);color:var(--text-secondary);}
|
||||
@@ -5921,7 +5967,8 @@ main.main > #mainPlugin{display:none;}
|
||||
.msg-body { padding-left: var(--msg-rail); padding-top: 8px; max-width: var(--msg-max); }
|
||||
.msg-body code,
|
||||
.msg-body pre,
|
||||
.msg-body table { font-family: 'SF Mono', ui-monospace, monospace; }
|
||||
.msg-body table { font-family:var(--font-mono); }
|
||||
body code[class*="language-"],body pre[class*="language-"],body pre code[class*="language-"]{font-family:var(--font-mono);}
|
||||
.msg-body:empty { display: none; }
|
||||
.assistant-turn { width: 100%; }
|
||||
.assistant-turn-blocks { display: flex; flex-direction: column; }
|
||||
@@ -6251,7 +6298,7 @@ main.main > #mainPlugin{display:none;}
|
||||
transition: max-height .22s ease, opacity .18s ease, padding .22s ease, border-top-color .22s ease;
|
||||
}
|
||||
.thinking-card.open .thinking-card-body { max-height: 260px; overflow-y: auto; opacity: 1; padding: 8px 12px; border-top-color: var(--accent-bg-strong); }
|
||||
.thinking-card-body pre { font-family: 'SF Mono', ui-monospace, monospace; font-size: 11px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||
.thinking-card-body pre { font-family:var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||
|
||||
/* ── Tool cards: quiet disclosure rows ── */
|
||||
.tool-card { background:transparent;border:0;border-left:0;border-radius:0;margin-top:0;margin-bottom:0; }
|
||||
@@ -6389,7 +6436,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.skill-file-breadcrumb{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid var(--border-subtle,var(--border));}
|
||||
.skill-file-back{font-size:12px;color:var(--accent-text);text-decoration:none;font-weight:500;}
|
||||
.skill-file-back:hover{text-decoration:underline;}
|
||||
.skill-file-path{font-size:12px;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.skill-file-path{font-size:12px;color:var(--muted);font-family:var(--font-mono);}
|
||||
.skill-file-code{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:12px 14px;font-size:12px;overflow:auto;max-width:960px;margin:0 auto;}
|
||||
|
||||
@media(max-width:700px){
|
||||
@@ -6404,7 +6451,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-row:last-child{border-bottom:none;}
|
||||
.detail-row-label{flex:0 0 140px;color:var(--muted);font-size:12px;}
|
||||
.detail-row-value{flex:1;min-width:0;color:var(--text);word-break:break-word;}
|
||||
.detail-row-value code{background:var(--sidebar);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12px;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.detail-row-value code{background:var(--sidebar);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12px;font-family:var(--font-mono);}
|
||||
.detail-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;border:1px solid var(--border);color:var(--muted);}
|
||||
.detail-badge.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong,var(--accent-bg));}
|
||||
.detail-badge.ok{background:rgba(100,200,100,.12);color:rgba(100,200,100,.95);border-color:rgba(100,200,100,.3);}
|
||||
@@ -6429,7 +6476,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-card-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;}
|
||||
.detail-expand-toggle{border:1px solid var(--border);background:var(--surface);color:var(--muted);border-radius:6px;padding:3px 8px;font-size:11px;line-height:1.3;cursor:pointer;}
|
||||
.detail-expand-toggle:hover{color:var(--text);border-color:var(--accent);}
|
||||
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:'SF Mono',ui-monospace,monospace;max-height:240px;overflow-y:auto;}
|
||||
.detail-prompt{background:var(--sidebar);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:12px;white-space:pre-wrap;line-height:1.55;color:var(--text);font-family:var(--font-mono);max-height:240px;overflow-y:auto;}
|
||||
.detail-prompt.expanded{max-height:none;overflow-y:visible;}
|
||||
.detail-run-item{border-top:1px solid var(--border);padding:8px 0;}
|
||||
.detail-run-item:first-child{border-top:none;}
|
||||
@@ -6447,11 +6494,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.workspace-artifact-empty{padding:16px 8px;color:var(--muted);font-size:12px;line-height:1.5;text-align:center;}
|
||||
.workspace-artifact-item{display:block;width:100%;text-align:left;background:var(--surface-subtle);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px;cursor:pointer;}
|
||||
.workspace-artifact-item:hover{border-color:var(--accent);}
|
||||
.workspace-artifact-filename{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere;}
|
||||
.workspace-artifact-directory{display:flex;min-width:0;overflow:hidden;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);}
|
||||
.workspace-artifact-directory-head{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-directory-tail{flex:0 0 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-meta{display:inline-block;margin-top:4px;padding:1px 5px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;line-height:1.3;}
|
||||
.workspace-artifact-filename{font-family:var(--font-mono);font-size:12px;overflow-wrap:anywhere;}
|
||||
.workspace-artifact-directory{display:flex;min-width:0;overflow:hidden;white-space:nowrap;font-family:var(--font-mono);font-size:11px;color:var(--muted);}
|
||||
.workspace-artifact-directory-head{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-directory-tail{flex:0 0 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;}
|
||||
.workspace-artifact-meta{display:inline-block;margin-top:4px;padding:1px 5px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;line-height:1.3;}
|
||||
.workspace-artifacts-count{opacity:.65;font-size:11px;}
|
||||
.rightpanel[data-active-tab="artifacts"] .breadcrumb-bar,.rightpanel[data-active-tab="artifacts"] .file-tree,.rightpanel[data-active-tab="artifacts"] #wsEmptyState{display:none!important;}
|
||||
.rightpanel[data-active-tab="artifacts"] .workspace-artifacts{display:block;}
|
||||
@@ -6498,8 +6545,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.cron-schedule-preset-field input[type="time"]{min-width:120px;}
|
||||
.cron-schedule-preset-field input[type="number"]{width:64px;}
|
||||
.cron-schedule-preset-time-hint{flex-basis:100%;margin-top:2px;}
|
||||
.cron-schedule-preview{font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);opacity:.85;}
|
||||
.detail-form-row textarea{resize:vertical;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;}
|
||||
.cron-schedule-preview{font-family:var(--font-mono);color:var(--text);opacity:.85;}
|
||||
.detail-form-row textarea{resize:vertical;font-family:var(--font-mono);font-size:12px;}
|
||||
.detail-form-row .detail-form-hint{font-size:11px;color:var(--muted);line-height:1.5;}
|
||||
.detail-form-warning{font-size:11px;line-height:1.5;border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:rgba(245,158,11,.98);border-radius:8px;padding:8px 10px;}
|
||||
.cron-once-warning{margin-top:2px;}
|
||||
@@ -6806,7 +6853,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
feels native to the WebUI rather than a one-off bridge UI. */
|
||||
.kanban-modal-overlay{
|
||||
position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
display:flex;align-items:center;align-items:safe center;justify-content:center;overflow-y:auto;
|
||||
z-index:1100;padding:24px;
|
||||
}
|
||||
.kanban-modal-overlay[hidden]{display:none;}
|
||||
@@ -6819,6 +6866,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
padding:18px 18px 16px;
|
||||
color:var(--text);
|
||||
box-sizing:border-box;
|
||||
max-height:calc(100vh - 48px);
|
||||
max-height:calc(100dvh - 48px);
|
||||
overflow-y:auto;
|
||||
}
|
||||
:root:not(.dark) .kanban-modal{
|
||||
background:linear-gradient(180deg,#fff,#f5f0e8);
|
||||
@@ -6865,7 +6915,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
}
|
||||
.kanban-modal-hint code{background:var(--input-bg);padding:1px 5px;border-radius:4px;font-family:'SF Mono',ui-monospace,Menlo,monospace;font-size:11px;color:var(--text);}
|
||||
.kanban-modal-hint code{background:var(--input-bg);padding:1px 5px;border-radius:4px;font-family:var(--font-mono);font-size:11px;color:var(--text);}
|
||||
.kanban-modal-hint em{color:var(--text);font-style:normal;font-weight:600;}
|
||||
.kanban-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;}
|
||||
.kanban-modal-error{
|
||||
@@ -7010,7 +7060,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-profile-lane-unassigned .kanban-profile-lane-head{border-bottom:1px solid color-mix(in srgb,var(--warning,#f5c542) 45%,var(--border));padding-bottom:6px}
|
||||
.kanban-board-in-lane{min-height:0;overflow-x:auto}
|
||||
.kanban-card-topline{display:flex;gap:6px;align-items:center;margin-bottom:4px;font-size:10px;color:var(--muted)}
|
||||
.kanban-card-id{font-family:var(--mono);opacity:.8}
|
||||
.kanban-card-id{font-family:var(--font-mono);opacity:.8}
|
||||
.kanban-badge{border:1px solid var(--border);border-radius:999px;padding:1px 6px;font-size:10px;color:var(--muted)}
|
||||
.kanban-badge.priority{color:var(--accent)}
|
||||
.kanban-badge.tenant{color:var(--text)}
|
||||
@@ -7023,7 +7073,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
|
||||
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
|
||||
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}
|
||||
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--font-mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}
|
||||
|
||||
@media (max-width: 640px){
|
||||
.kanban-board{scroll-snap-type:x mandatory;}
|
||||
@@ -7043,11 +7093,25 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
/* Modal scales to viewport width on phones with reasonable padding.
|
||||
Ensure it's on screen and scrollable */
|
||||
.kanban-modal-overlay{align-items:safe center;overflow-y:auto;padding:12px;}
|
||||
.kanban-modal{padding:16px 16px 14px;border-radius:14px;}
|
||||
.kanban-modal{
|
||||
padding:16px 16px 14px;border-radius:14px;
|
||||
max-height:calc(100dvh - 24px);overflow-y:auto;
|
||||
}
|
||||
.kanban-modal-row-inline{flex-direction:column;gap:0;}
|
||||
.kanban-column-body{overscroll-behavior:auto;}
|
||||
}
|
||||
|
||||
/* Short landscape phones (e.g. 480×320): a tall form (long localized labels)
|
||||
can exceed the viewport height. Anchor the modal to the top instead of
|
||||
centering so the capped, internally-scrollable modal is reachable from the
|
||||
top of the overlay. Centering here would push the lower rows below the fold
|
||||
even though the modal itself scrolls. The max-height cap + overflow stay so
|
||||
the inner form scrolls; only the alignment changes. */
|
||||
@media (max-width: 640px) and (max-height: 480px){
|
||||
.kanban-modal-overlay{align-items:flex-start;overflow-y:auto;}
|
||||
.kanban-modal{max-height:calc(100dvh - 24px);}
|
||||
}
|
||||
|
||||
/* ── Logs panel (#1455) ───────────────────────────────────────────────────── */
|
||||
main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.logs-control-panel{display:flex;flex-direction:column;gap:8px;padding:12px;overflow-y:auto;}
|
||||
@@ -7058,10 +7122,10 @@ main.main.showing-logs > #mainLogs{display:flex;}
|
||||
.logs-copy{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:8px;padding:7px 10px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s;}
|
||||
.logs-copy:hover{background:var(--hover-bg);border-color:var(--border2);}
|
||||
.logs-copy.compact{padding:6px 10px;white-space:nowrap;}
|
||||
.logs-status{font-size:12px;color:var(--muted);margin-top:3px;font-family:'SF Mono',ui-monospace,monospace;}
|
||||
.logs-status{font-size:12px;color:var(--muted);margin-top:3px;font-family:var(--font-mono);}
|
||||
.logs-main-body{padding:18px 24px;}
|
||||
.logs-content{max-width:1200px;}
|
||||
.logs-output{min-height:320px;max-height:calc(100vh - 170px);overflow:auto;background:var(--code-bg);border:1px solid var(--border);border-radius:12px;padding:12px 0;font-family:'SF Mono','Fira Code',ui-monospace,monospace;font-size:12px;line-height:1.55;color:var(--pre-text);white-space:pre;}
|
||||
.logs-output{min-height:320px;max-height:calc(100vh - 170px);overflow:auto;background:var(--code-bg);border:1px solid var(--border);border-radius:12px;padding:12px 0;font-family:var(--font-mono);font-size:12px;line-height:1.55;color:var(--pre-text);white-space:pre;}
|
||||
.logs-output.wrap{white-space:pre-wrap;overflow-wrap:anywhere;}
|
||||
.log-line{padding:0 14px;min-height:1.55em;border-left:3px solid transparent;}
|
||||
.log-line:hover{background:rgba(255,255,255,.04);}
|
||||
|
||||
+115
-6
@@ -15,6 +15,11 @@ const TERMINAL_UI={
|
||||
resizing:false,
|
||||
resizeStartY:0,
|
||||
resizeStartHeight:0,
|
||||
lastAppliedTheme:null,
|
||||
lastAppliedFontFamily:null,
|
||||
fontFitFrame:null,
|
||||
fontLoadGeneration:0,
|
||||
fontLoadRequest:null,
|
||||
};
|
||||
|
||||
const TERMINAL_HEIGHT_DEFAULT=260;
|
||||
@@ -114,8 +119,78 @@ function _terminalTheme(){
|
||||
};
|
||||
}
|
||||
|
||||
function syncComposerTerminalTheme(){
|
||||
if(TERMINAL_UI.term)TERMINAL_UI.term.options.theme=_terminalTheme();
|
||||
function _terminalMonoFont(){
|
||||
return _terminalCssVar(
|
||||
'--font-mono',
|
||||
'ui-monospace,"SFMono-Regular","SF Mono",Menlo,Consolas,"Liberation Mono",monospace'
|
||||
);
|
||||
}
|
||||
|
||||
function _terminalThemesEqual(left,right){
|
||||
if(left===right)return true;
|
||||
if(!left||!right)return false;
|
||||
const leftKeys=Object.keys(left);
|
||||
const rightKeys=Object.keys(right);
|
||||
return leftKeys.length===rightKeys.length&&leftKeys.every(key=>Object.prototype.hasOwnProperty.call(right,key)&&left[key]===right[key]);
|
||||
}
|
||||
|
||||
function _scheduleTerminalFontFit(term){
|
||||
if(TERMINAL_UI.fontFitFrame!==null)return;
|
||||
TERMINAL_UI.fontFitFrame=requestAnimationFrame(()=>{
|
||||
TERMINAL_UI.fontFitFrame=null;
|
||||
if(TERMINAL_UI.term!==term||!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
|
||||
_fitTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
function _watchTerminalFontLoad(term,fontFamily){
|
||||
const request={
|
||||
generation:TERMINAL_UI.fontLoadGeneration+1,
|
||||
term,
|
||||
fontFamily,
|
||||
};
|
||||
TERMINAL_UI.fontLoadGeneration=request.generation;
|
||||
TERMINAL_UI.fontLoadRequest=request;
|
||||
const fontSet=document.fonts;
|
||||
if(!fontSet||typeof fontSet.load!=='function')return;
|
||||
const fontSize=Number(term.options&&term.options.fontSize);
|
||||
const fontShorthand=(Number.isFinite(fontSize)&&fontSize>0?fontSize:13)+'px '+fontFamily;
|
||||
let load;
|
||||
try{
|
||||
load=document.fonts.load(fontShorthand);
|
||||
}catch(_){
|
||||
return;
|
||||
}
|
||||
Promise.resolve(load).then(fontFaces=>{
|
||||
if(!fontFaces||fontFaces.length===0)return;
|
||||
if(TERMINAL_UI.fontLoadRequest!==request
|
||||
||TERMINAL_UI.fontLoadGeneration!==request.generation
|
||||
||TERMINAL_UI.term!==term
|
||||
||!TERMINAL_UI.open
|
||||
||TERMINAL_UI.collapsed
|
||||
||TERMINAL_UI.lastAppliedFontFamily!==fontFamily)return;
|
||||
_scheduleTerminalFontFit(term);
|
||||
},()=>{});
|
||||
}
|
||||
|
||||
function syncComposerTerminalAppearance(forceFontLoad=false){
|
||||
if(!TERMINAL_UI.term)return;
|
||||
const term=TERMINAL_UI.term;
|
||||
const theme=_terminalTheme();
|
||||
const fontFamily=_terminalMonoFont();
|
||||
if(!_terminalThemesEqual(TERMINAL_UI.lastAppliedTheme,theme)){
|
||||
term.options.theme=theme;
|
||||
TERMINAL_UI.lastAppliedTheme={...theme};
|
||||
}
|
||||
const fontFamilyChanged=TERMINAL_UI.lastAppliedFontFamily!==fontFamily;
|
||||
if(fontFamilyChanged){
|
||||
term.options.fontFamily=fontFamily;
|
||||
TERMINAL_UI.lastAppliedFontFamily=fontFamily;
|
||||
_scheduleTerminalFontFit(term);
|
||||
}
|
||||
if(fontFamilyChanged||forceFontLoad){
|
||||
_watchTerminalFontLoad(term,fontFamily);
|
||||
}
|
||||
}
|
||||
|
||||
function _xtermReady(){
|
||||
@@ -130,13 +205,15 @@ function _ensureXterm(){
|
||||
surface.textContent='Terminal library failed to load. Check network access to cdn.jsdelivr.net.';
|
||||
return null;
|
||||
}
|
||||
const theme=_terminalTheme();
|
||||
const fontFamily=_terminalMonoFont();
|
||||
const term=new window.Terminal({
|
||||
cursorBlink:true,
|
||||
fontSize:13,
|
||||
fontFamily:'Menlo, Monaco, Consolas, "Liberation Mono", monospace',
|
||||
fontFamily,
|
||||
scrollback:1000,
|
||||
convertEol:false,
|
||||
theme:_terminalTheme(),
|
||||
theme,
|
||||
});
|
||||
let fitAddon=null;
|
||||
if(window.FitAddon&&typeof window.FitAddon.FitAddon==='function'){
|
||||
@@ -162,7 +239,10 @@ function _ensureXterm(){
|
||||
});
|
||||
TERMINAL_UI.term=term;
|
||||
TERMINAL_UI.fitAddon=fitAddon;
|
||||
TERMINAL_UI.lastAppliedTheme={...theme};
|
||||
TERMINAL_UI.lastAppliedFontFamily=fontFamily;
|
||||
_fitTerminal();
|
||||
_watchTerminalFontLoad(term,fontFamily);
|
||||
return term;
|
||||
}
|
||||
|
||||
@@ -571,12 +651,20 @@ function expandComposerTerminal(opts){
|
||||
}
|
||||
|
||||
function _disposeXterm(){
|
||||
TERMINAL_UI.fontLoadGeneration+=1;
|
||||
TERMINAL_UI.fontLoadRequest=null;
|
||||
if(TERMINAL_UI.fontFitFrame!==null){
|
||||
if(typeof cancelAnimationFrame==='function')cancelAnimationFrame(TERMINAL_UI.fontFitFrame);
|
||||
TERMINAL_UI.fontFitFrame=null;
|
||||
}
|
||||
if(TERMINAL_UI.term){
|
||||
try{TERMINAL_UI.term.dispose();}catch(_){}
|
||||
}
|
||||
TERMINAL_UI.term=null;
|
||||
TERMINAL_UI.fitAddon=null;
|
||||
TERMINAL_UI.typedLine='';
|
||||
TERMINAL_UI.lastAppliedTheme=null;
|
||||
TERMINAL_UI.lastAppliedFontFamily=null;
|
||||
const {surface}= _terminalEls();
|
||||
if(surface)surface.textContent='';
|
||||
}
|
||||
@@ -696,8 +784,29 @@ window.addEventListener('resize',()=>{
|
||||
});
|
||||
|
||||
if(window.MutationObserver){
|
||||
new MutationObserver(syncComposerTerminalTheme).observe(document.documentElement,{
|
||||
new MutationObserver(()=>syncComposerTerminalAppearance()).observe(document.documentElement,{
|
||||
attributes:true,
|
||||
attributeFilter:['class','data-skin'],
|
||||
attributeFilter:['class','data-skin','style'],
|
||||
});
|
||||
|
||||
const terminalHead=document.head;
|
||||
if(terminalHead){
|
||||
const terminalHeadStylesheetLoadListener=(event)=>{
|
||||
const target=event && event.target;
|
||||
if(!target||typeof target.tagName!=='string'||target.tagName.toLowerCase()!=='link')return;
|
||||
const rel=typeof target.getAttribute==='function'
|
||||
? target.getAttribute('rel')
|
||||
: target.rel;
|
||||
if(!String(rel||'').toLowerCase().split(/\s+/).includes('stylesheet'))return;
|
||||
syncComposerTerminalAppearance(true);
|
||||
};
|
||||
terminalHead.addEventListener('load',terminalHeadStylesheetLoadListener,true);
|
||||
new MutationObserver(()=>syncComposerTerminalAppearance(true)).observe(terminalHead,{
|
||||
attributes:true,
|
||||
attributeFilter:['href','media','disabled'],
|
||||
childList:true,
|
||||
subtree:true,
|
||||
characterData:true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1615
-247
File diff suppressed because it is too large
Load Diff
+31
-6
@@ -609,9 +609,16 @@ function renderSessionArtifacts(){
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function projectSessionArtifactsForOwner(sessionId){
|
||||
if(!sessionId||!S.session||S.session.session_id!==sessionId) return false;
|
||||
if(typeof _isSessionCurrentPane!=='function'||!_isSessionCurrentPane(sessionId)) return false;
|
||||
renderSessionArtifacts();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _workspacePathExists(path){
|
||||
if(!S.session||!path) return false;
|
||||
const parts=String(path).split('/').filter(Boolean);
|
||||
const parts=String(path).replace(/\\/g,'/').split('/').filter(Boolean);
|
||||
const name=parts.pop();
|
||||
if(!name) return false;
|
||||
const dir=parts.length?parts.join('/'):'.';
|
||||
@@ -622,9 +629,12 @@ async function _workspacePathExists(path){
|
||||
async function openArtifactPath(path){
|
||||
if(!path) return;
|
||||
switchWorkspacePanelTab('files');
|
||||
let rel = path.replace(/^~\//,'').replace(/^\.\/+/,'');
|
||||
// Normalize backslash separators to '/' first — Windows absolute paths
|
||||
// (e.g. "D:\workspace\dir\file") otherwise break prefix-strip and the
|
||||
// /api/list existence check (which splits on '/').
|
||||
let rel = String(path).replace(/\\/g,'/').replace(/^~\//,'').replace(/^(?:\.\/)+/,'');
|
||||
// Strip workspace prefix so /api/list receives a workspace-relative path.
|
||||
const ws = S.session && S.session.workspace;
|
||||
const ws = (S.session && S.session.workspace || '').replace(/\\/g,'/');
|
||||
if(ws){
|
||||
const normWs = ws.replace(/\/+$/,'') + '/';
|
||||
if(rel.startsWith(normWs)) rel = rel.slice(normWs.length);
|
||||
@@ -729,6 +739,7 @@ async function loadDir(path, opts={}){
|
||||
// rejected here instead of painting the wrong profile's files.
|
||||
try{
|
||||
if(!path||path==='.'||refreshExpanded){
|
||||
if(typeof _syncWorkspaceBirthtimeSupportScope==='function') _syncWorkspaceBirthtimeSupportScope((S.session&&S.session.workspace)||'');
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state after root and refresh resets
|
||||
}
|
||||
@@ -738,6 +749,14 @@ async function loadDir(path, opts={}){
|
||||
`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path||'.')}`
|
||||
);
|
||||
if(!S.session||S.session.session_id!==sessionId||treeGen!==_wsTreeGen)return;
|
||||
if(data.workspace_recovered&&data.workspace){
|
||||
S.session.workspace=String(data.workspace);
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs();
|
||||
if(typeof syncWorkspaceDisplays==='function')syncWorkspaceDisplays();
|
||||
if(typeof syncTerminalButton==='function')syncTerminalButton();
|
||||
showToast(t('workspace_recovered_notice',S.session.workspace),5000,'warning');
|
||||
}
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// #2673 — refresh Artifacts tab when its source data (the file tree) updates.
|
||||
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
|
||||
@@ -1406,16 +1425,22 @@ async function _collectFilesFromEntry(entry, relPrefix) {
|
||||
async function _collectOsDropUploads(dataTransfer) {
|
||||
const out = [];
|
||||
const items = dataTransfer.items ? [...dataTransfer.items] : [];
|
||||
if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
const files = dataTransfer.files ? [...dataTransfer.files] : [];
|
||||
if (items.length) {
|
||||
const entries = [];
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const getAsEntry = item.getAsEntry || item.webkitGetAsEntry;
|
||||
const entry = typeof getAsEntry === 'function' ? getAsEntry.call(item) : null;
|
||||
if (!entry) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
out.push(...await _collectFilesFromEntry(entry, ''));
|
||||
}
|
||||
if (out.length) return out;
|
||||
}
|
||||
for (const file of dataTransfer.files) {
|
||||
for (const file of files) {
|
||||
out.push({ file, relDir: '' });
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Shared scaffolding for the auxiliary title-generation test suites.
|
||||
|
||||
``test_title_aux_routing.py`` and ``test_2235_initial_aux_title.py`` both need
|
||||
``agent.auxiliary_client`` importable so ``api.streaming`` can be patched
|
||||
against it. Both used to install synthetic modules at import time with
|
||||
``sys.modules.setdefault`` and never remove them, so whichever ran first left
|
||||
the stub in place for the rest of the process and the next file to import
|
||||
``agent.context_compressor`` got the stub instead of the real module (#6630).
|
||||
|
||||
Scope the install to the test that needs it and let ``mock.patch.dict`` own the
|
||||
restore: it snapshots the mapping and puts it back on exit, so a key that was
|
||||
absent is deleted, a key that was present is restored by identity, and a key
|
||||
holding ``None`` stays ``None``. ``tests/test_4413_seed_provider_models.py``
|
||||
already stubs ``sys.modules`` this way, including the ``None`` case.
|
||||
|
||||
One constraint that comes with it: ``patch.dict`` restores by clearing the
|
||||
mapping and replacing it wholesale, so any module first imported inside the
|
||||
scope is evicted on exit and re-imported later as a new object. That is
|
||||
harmless for these suites, which import everything they touch at collection
|
||||
time, but it is the thing to check before reusing this helper around code
|
||||
that imports lazily.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def auxiliary_client_modules():
|
||||
"""Context manager installing stub ``agent`` / ``agent.auxiliary_client``.
|
||||
|
||||
The stubs are fresh objects owned by this helper, so the ``auxiliary_client``
|
||||
attribute is set on our own module and no pre-existing ``agent`` module is
|
||||
mutated. Restoration is therefore entirely ``patch.dict``'s job.
|
||||
|
||||
``agent.portal_tags`` is stubbed with a real ``ContextVar`` behind the same
|
||||
set/reset/get surface as the Agent runtime (tokens are truthy objects, and
|
||||
reset restores the previous value), so title-generation tests can assert
|
||||
conversation-context publication/restoration without a real Agent runtime
|
||||
(#7470).
|
||||
"""
|
||||
import contextvars as _contextvars
|
||||
|
||||
agent_stub = types.ModuleType("agent")
|
||||
auxiliary_client_stub = types.ModuleType("agent.auxiliary_client")
|
||||
portal_tags_stub = types.ModuleType("agent.portal_tags")
|
||||
agent_stub.auxiliary_client = auxiliary_client_stub
|
||||
portal_state = {"set_calls": [], "reset_calls": []}
|
||||
_conversation_cv = _contextvars.ContextVar("webui_test_conversation_id", default=None)
|
||||
|
||||
def _set_conversation_context(conversation_id):
|
||||
portal_state["set_calls"].append(conversation_id)
|
||||
return _conversation_cv.set(conversation_id or None)
|
||||
|
||||
def _reset_conversation_context(token):
|
||||
portal_state["reset_calls"].append(token)
|
||||
_conversation_cv.reset(token)
|
||||
|
||||
def _get_conversation_context():
|
||||
return _conversation_cv.get()
|
||||
|
||||
portal_tags_stub.set_conversation_context = _set_conversation_context
|
||||
portal_tags_stub.reset_conversation_context = _reset_conversation_context
|
||||
portal_tags_stub.get_conversation_context = _get_conversation_context
|
||||
portal_tags_stub._portal_state = portal_state
|
||||
agent_stub.portal_tags = portal_tags_stub
|
||||
return mock.patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"agent": agent_stub,
|
||||
"agent.auxiliary_client": auxiliary_client_stub,
|
||||
"agent.portal_tags": portal_tags_stub,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def patch_tg_config(config_dict):
|
||||
"""Make ``_get_auxiliary_task_config`` return ``config_dict``."""
|
||||
return mock.patch(
|
||||
"agent.auxiliary_client._get_auxiliary_task_config",
|
||||
return_value=config_dict,
|
||||
create=True,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "issue6611_regeneration_rows.json"
|
||||
_EXPECTED_ROWS = [
|
||||
{"role": "user", "content": "same prompt"},
|
||||
{"role": "assistant", "content": "provider failed", "_error": True},
|
||||
]
|
||||
|
||||
|
||||
def load_issue6611_fixture():
|
||||
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
assert set(fixture) == {"issue", "rows"}
|
||||
assert fixture["issue"] == 6611
|
||||
assert isinstance(fixture["rows"], list)
|
||||
assert fixture["rows"] == _EXPECTED_ROWS
|
||||
assert all(set(row) == {"role", "content"} for row in fixture["rows"][:1])
|
||||
assert set(fixture["rows"][1]) == {"role", "content", "_error"}
|
||||
assert [row["role"] for row in fixture["rows"]] == ["user", "assistant"]
|
||||
assert fixture["rows"][1]["_error"] is True
|
||||
return fixture
|
||||
@@ -137,6 +137,32 @@ function collectRenderViolations(scopeSelector, options) {
|
||||
return null
|
||||
}
|
||||
|
||||
// An interactive element that currently sits outside the viewport is NOT a
|
||||
// "degenerate/off-viewport" defect when it lives inside a scrollable ancestor
|
||||
// (overflow-y/x auto|scroll whose content actually overflows): it is reachable
|
||||
// by scrolling that container. Flagging it produces a false positive for a
|
||||
// valid pattern — e.g. a tall dialog with overflow-y:auto on a short viewport,
|
||||
// where a lower field is momentarily below the fold but scrolls into view.
|
||||
// Only report off-viewport when the element is genuinely unreachable (no
|
||||
// scrollable ancestor can bring the off-axis edge back on-screen).
|
||||
function reachableByScrollableAncestor(node, offLeft, offRight, offTop, offBottom) {
|
||||
var cur = node.parentElement
|
||||
while (cur && cur !== root.parentElement) {
|
||||
var cs = getComputed(cur)
|
||||
var scrollsY = (cs.overflowY === "auto" || cs.overflowY === "scroll") &&
|
||||
cur.scrollHeight > cur.clientHeight + 1
|
||||
var scrollsX = (cs.overflowX === "auto" || cs.overflowX === "scroll") &&
|
||||
cur.scrollWidth > cur.clientWidth + 1
|
||||
// A vertical off-viewport (top past the fold, or bottom above it) is
|
||||
// absorbed by a Y-scrollable ancestor; likewise X off-viewport by an
|
||||
// X-scrollable ancestor.
|
||||
if ((offTop || offBottom) && scrollsY) return true
|
||||
if ((offLeft || offRight) && scrollsX) return true
|
||||
cur = cur.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function walk(node) {
|
||||
if (node.nodeType !== 1) return
|
||||
var cs = getComputed(node)
|
||||
@@ -226,7 +252,15 @@ function collectRenderViolations(scopeSelector, options) {
|
||||
} else if (isVisibleForDegenerate(child)) {
|
||||
var dr = child.getBoundingClientRect()
|
||||
var zeroSize = dr.width === 0 || dr.height === 0
|
||||
var offViewport = dr.right < 0 || dr.bottom < 0 || dr.left > win.innerWidth || dr.top > win.innerHeight
|
||||
var offLeft = dr.right < 0
|
||||
var offRight = dr.left > win.innerWidth
|
||||
var offTop = dr.bottom < 0
|
||||
var offBottom = dr.top > win.innerHeight
|
||||
var offViewport = offLeft || offRight || offTop || offBottom
|
||||
// Reachable-by-scroll inside a scrollable ancestor is not a defect.
|
||||
if (offViewport && reachableByScrollableAncestor(child, offLeft, offRight, offTop, offBottom)) {
|
||||
offViewport = false
|
||||
}
|
||||
if (zeroSize || offViewport) {
|
||||
violations.push({
|
||||
type: "degenerate",
|
||||
@@ -302,7 +336,14 @@ function collectRenderViolations(scopeSelector, options) {
|
||||
if (dNode.tagName === "INPUT" && dNode.getAttribute("type") === "hidden") continue
|
||||
var dr2 = dNode.getBoundingClientRect()
|
||||
var zs = dr2.width === 0 || dr2.height === 0
|
||||
var ov = dr2.right < 0 || dr2.bottom < 0 || dr2.left > win.innerWidth || dr2.top > win.innerHeight
|
||||
var off2Left = dr2.right < 0
|
||||
var off2Right = dr2.left > win.innerWidth
|
||||
var off2Top = dr2.bottom < 0
|
||||
var off2Bottom = dr2.top > win.innerHeight
|
||||
var ov = off2Left || off2Right || off2Top || off2Bottom
|
||||
if (ov && reachableByScrollableAncestor(dNode, off2Left, off2Right, off2Top, off2Bottom)) {
|
||||
ov = false
|
||||
}
|
||||
if (zs || ov) {
|
||||
violations.push({
|
||||
type: "degenerate",
|
||||
|
||||
@@ -5,6 +5,8 @@ This test boots the real WebUI server with isolated state, drives the real chat
|
||||
composer in Chromium, and supplies deterministic runtime events through the
|
||||
existing Hermes Gateway Runs API. It proves that one assistant turn keeps the
|
||||
same semantic activity across live streaming, settlement, and a hard reload.
|
||||
|
||||
Proposed in #6247; first implementation slice merged as #6251.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -488,6 +490,11 @@ def _activity_snapshot(page) -> dict:
|
||||
Array.from(document.querySelectorAll('.assistant-turn')).pop() || null;
|
||||
const groups = turn ? Array.from(turn.querySelectorAll('[data-anchor-scene-owner="1"]')) : [];
|
||||
const rows = turn ? Array.from(turn.querySelectorAll('[data-anchor-scene-row="1"]')) : [];
|
||||
const sceneRows = assistants.flatMap(message =>
|
||||
message && message._anchor_activity_scene && Array.isArray(message._anchor_activity_scene.activity_rows)
|
||||
? message._anchor_activity_scene.activity_rows
|
||||
: []
|
||||
);
|
||||
const visibleFinal = turn ? Array.from(turn.querySelectorAll('.assistant-segment .msg-body'))
|
||||
.filter(el => {
|
||||
const segment = el.closest('.assistant-segment');
|
||||
@@ -518,6 +525,11 @@ def _activity_snapshot(page) -> dict:
|
||||
})),
|
||||
rows: rows.map(row => ({
|
||||
role: row.getAttribute('data-anchor-row-role'),
|
||||
rowId: row.getAttribute('data-anchor-row-id') || '',
|
||||
toolCallId: (sceneRows.find(sceneRow =>
|
||||
String(sceneRow && (sceneRow.row_id || sceneRow.local_id) || '') ===
|
||||
String(row.getAttribute('data-anchor-row-id') || '')
|
||||
) || {}).tool_call_id || '',
|
||||
source: row.getAttribute('data-anchor-source-event-type'),
|
||||
status: row.getAttribute('data-anchor-row-status'),
|
||||
tool: row.getAttribute('data-tool-name'),
|
||||
@@ -716,10 +728,16 @@ def main() -> int:
|
||||
f"Unsupported LIFECYCLE_SCENARIO {scenario!r}; "
|
||||
"expected 'normal' or 'terminal-error'"
|
||||
)
|
||||
if TEST_BITE not in {"", "drop-anchor-persistence", "drop-terminal-anchor-row"}:
|
||||
if TEST_BITE not in {
|
||||
"",
|
||||
"drop-anchor-persistence",
|
||||
"drop-terminal-anchor-row",
|
||||
"settle-worklog-frame-proof",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'drop-anchor-persistence', 'drop-terminal-anchor-row'"
|
||||
"expected one of '', 'drop-anchor-persistence', "
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof'"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
|
||||
raise ValueError(
|
||||
@@ -851,6 +869,65 @@ def main() -> int:
|
||||
print("OK live activity: one Anchor worklog with reasoning + completed tool")
|
||||
_wait_for_live_anchor_projection(page)
|
||||
|
||||
# #6414 proof: the terminal handoff may rebuild DOM, but a browser paint
|
||||
# must never expose an expanded empty Worklog shell or an activity-less
|
||||
# gap. Sample after rAF callbacks for a frame have drained; DOM mutations
|
||||
# within one task or earlier rAF callbacks are not user-visible frames.
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const samples = [];
|
||||
const settledWorklogRemovals = [];
|
||||
let active = true;
|
||||
const transcript = document.querySelector('#msgInner');
|
||||
const observer = new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
if (record.target !== transcript) continue;
|
||||
for (const node of record.removedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (node.querySelector('[data-anchor-settled-scene-owner="1"]')) {
|
||||
settledWorklogRemovals.push(node.outerHTML.slice(0, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(transcript, {childList: true});
|
||||
const scheduleSample = () => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(sample, 0);
|
||||
});
|
||||
};
|
||||
const sample = () => {
|
||||
const liveTurn = document.querySelector('#liveAssistantTurn');
|
||||
const groups = Array.from(document.querySelectorAll(
|
||||
'.assistant-turn [data-anchor-scene-owner="1"]'
|
||||
)).map(group => {
|
||||
const summary = group.querySelector(
|
||||
'.tool-worklog-summary,.tool-call-group-summary'
|
||||
);
|
||||
return {
|
||||
collapsed: group.classList.contains('tool-call-group-collapsed'),
|
||||
rows: group.querySelectorAll('[data-anchor-scene-row="1"]').length,
|
||||
summary: (summary && summary.innerText || '').trim(),
|
||||
};
|
||||
});
|
||||
const liveRows = liveTurn ? liveTurn.querySelectorAll(
|
||||
'[data-anchor-scene-row="1"],.tool-card-row,.wl-reason'
|
||||
).length : 0;
|
||||
samples.push({live: Boolean(liveTurn), liveRows, groups});
|
||||
if (active) scheduleSample();
|
||||
};
|
||||
window.__lifecycleSettleFrameProof = {
|
||||
stop: () => {
|
||||
active = false;
|
||||
observer.disconnect();
|
||||
return {samples: samples.slice(), settledWorklogRemovals};
|
||||
},
|
||||
};
|
||||
scheduleSample();
|
||||
}"""
|
||||
)
|
||||
|
||||
gateway.release_settle.set()
|
||||
if not gateway.final_prefix_ready.wait(timeout=10):
|
||||
raise AssertionError("mock Gateway did not emit the final-answer prefix")
|
||||
@@ -881,6 +958,44 @@ def main() -> int:
|
||||
)
|
||||
session_id = page.evaluate("S.session && S.session.session_id")
|
||||
assert session_id, "active session id missing after settlement"
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
page.wait_for_timeout(100)
|
||||
settle_proof = page.evaluate(
|
||||
"window.__lifecycleSettleFrameProof && window.__lifecycleSettleFrameProof.stop()"
|
||||
)
|
||||
assert isinstance(settle_proof, dict), settle_proof
|
||||
settle_frames = settle_proof.get("samples")
|
||||
assert isinstance(settle_frames, list) and settle_frames, settle_frames
|
||||
assert not settle_proof.get("settledWorklogRemovals"), {
|
||||
"message": "#6414: settlement rebuilt and removed the just-settled Worklog",
|
||||
"settled_worklog_removals": settle_proof.get("settledWorklogRemovals"),
|
||||
}
|
||||
for frame in settle_frames:
|
||||
groups = frame["groups"]
|
||||
valid_settled_surface = any(
|
||||
(group["collapsed"] and group["summary"])
|
||||
or (not group["collapsed"] and group["rows"] > 0)
|
||||
for group in groups
|
||||
)
|
||||
valid_live_surface = frame["live"] and frame["liveRows"] > 0
|
||||
assert valid_live_surface or valid_settled_surface, {
|
||||
"message": "#6414: a painted settle frame exposed an empty Worklog shell "
|
||||
"or no activity surface",
|
||||
"frame": frame,
|
||||
"frames": settle_frames,
|
||||
}
|
||||
page.screenshot(
|
||||
path=str(artifact_dir / "settle-worklog-frame-proof.png"),
|
||||
full_page=True,
|
||||
)
|
||||
(artifact_dir / "settle-worklog-frame-proof.json").write_text(
|
||||
json.dumps(settle_proof, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
"OK settle-frame proof: every painted frame kept live activity "
|
||||
"or a non-empty settled Worklog surface"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row":
|
||||
scene = _wait_for_persisted_scene(
|
||||
base_url,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Public browser proof for ID-linked historical tool transcript hydration.
|
||||
|
||||
This boots the real WebUI with isolated state, imports a persisted transcript
|
||||
through the public API, and loads it through the normal browser session path.
|
||||
The supported OpenAI-compatible declaration/result/final chain must hydrate to
|
||||
one Anchor Worklog and remain equivalent after hard reload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from browser_conversation_lifecycle import (
|
||||
_activity_snapshot,
|
||||
_capture_page_errors,
|
||||
_expand_settled_worklog,
|
||||
_start_webui_server,
|
||||
_terminate_process,
|
||||
)
|
||||
|
||||
|
||||
FINAL_TEXT = "Historical hydration proof final answer."
|
||||
TOOL_IDS = ("historical-tool-first", "historical-tool-second")
|
||||
TEST_BITE = os.environ.get("HISTORICAL_HYDRATION_TEST_BITE", "").strip()
|
||||
|
||||
|
||||
def _historical_messages() -> list[dict]:
|
||||
first_result_id = (
|
||||
"missing-historical-tool-id"
|
||||
if TEST_BITE == "break-tool-link"
|
||||
else TOOL_IDS[0]
|
||||
)
|
||||
return [
|
||||
{"role": "user", "content": "Inspect both historical files."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": TOOL_IDS[0],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal",
|
||||
"arguments": '{"command":"pwd"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": TOOL_IDS[1],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": '{"path":"README.md"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": TOOL_IDS[1],
|
||||
"content": "README historical contents",
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": first_result_id,
|
||||
"content": "/isolated/workspace",
|
||||
},
|
||||
{"role": "assistant", "content": FINAL_TEXT},
|
||||
]
|
||||
|
||||
|
||||
def _assert_hydrated(snapshot: dict) -> None:
|
||||
assert snapshot["live"] is False, snapshot
|
||||
assert snapshot["groupCount"] == 1, snapshot
|
||||
assert snapshot["clientState"]["busy"] is False, snapshot
|
||||
rows = [row for row in snapshot["rows"] if row["role"] == "tool"]
|
||||
assert [row["tool"] for row in rows] == ["terminal", "read_file"], snapshot
|
||||
assert len(rows) == 2, snapshot
|
||||
assert [row["toolCallId"] for row in rows] == list(TOOL_IDS), snapshot
|
||||
assert all(row["rowId"].startswith(f"{row['toolCallId']}:") for row in rows), snapshot
|
||||
assert "/isolated/workspace" in rows[0]["text"], snapshot
|
||||
assert "README historical contents" not in rows[0]["text"], snapshot
|
||||
assert "README historical contents" in rows[1]["text"], snapshot
|
||||
assert "/isolated/workspace" not in rows[1]["text"], snapshot
|
||||
assert sum(FINAL_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot
|
||||
assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot
|
||||
|
||||
|
||||
def _semantic_snapshot(snapshot: dict) -> dict:
|
||||
return {
|
||||
"tools": [
|
||||
{"tool": row["tool"], "text": row["text"]}
|
||||
for row in snapshot["rows"]
|
||||
if row["role"] == "tool"
|
||||
],
|
||||
"final": snapshot["visibleFinal"],
|
||||
}
|
||||
|
||||
|
||||
def _import_and_load(page) -> str:
|
||||
return page.evaluate(
|
||||
"""async ({messages, finalText}) => {
|
||||
const response = await fetch('/api/session/import', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
title: 'Historical hydration browser proof',
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.session || !data.session.session_id) {
|
||||
throw new Error(`session import failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
await loadSession(data.session.session_id);
|
||||
await new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 10000;
|
||||
const check = () => {
|
||||
const text = (document.querySelector('#msgInner') || {}).innerText || '';
|
||||
if (text.includes(finalText)) return resolve();
|
||||
if (Date.now() >= deadline) return reject(new Error('loaded session never rendered final text'));
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
return data.session.session_id;
|
||||
}""",
|
||||
{"messages": _historical_messages(), "finalText": FINAL_TEXT},
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if TEST_BITE not in {"", "break-tool-link"}:
|
||||
raise ValueError(
|
||||
f"Unsupported HISTORICAL_HYDRATION_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'break-tool-link'"
|
||||
)
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
print("SETUP FAIL: playwright is not installed", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
state_tmp = tempfile.TemporaryDirectory(prefix="hermes-historical-hydration-")
|
||||
state_dir = Path(state_tmp.name)
|
||||
artifact_env = str(os.environ.get("HISTORICAL_HYDRATION_ARTIFACT_DIR") or "").strip()
|
||||
artifact_dir_owned = not bool(artifact_env)
|
||||
artifact_dir = Path(artifact_env) if artifact_env else Path(
|
||||
tempfile.mkdtemp(prefix="hermes-historical-hydration-artifacts-")
|
||||
)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent_dir = state_dir / "no-agent"
|
||||
workspace_dir = state_dir / "workspace"
|
||||
agent_dir.mkdir()
|
||||
workspace_dir.mkdir()
|
||||
(agent_dir / "run_agent.py").write_text("\"\"\"Test-only agent stub.\"\"\"\n", encoding="utf-8")
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.endswith("_API_KEY"):
|
||||
env.pop(key, None)
|
||||
for key in (
|
||||
"API_SERVER_KEY",
|
||||
"HERMES_WEBUI_PASSWORD",
|
||||
"HERMES_WEBUI_EXTENSION_DIR",
|
||||
"HERMES_WEBUI_EXTENSION_MANIFEST",
|
||||
):
|
||||
env.pop(key, None)
|
||||
env.update({
|
||||
"HERMES_WEBUI_HOST": "127.0.0.1",
|
||||
"HERMES_WEBUI_STATE_DIR": str(state_dir / "webui-state"),
|
||||
"HERMES_HOME": str(state_dir / "hermes-home"),
|
||||
"HERMES_BASE_HOME": str(state_dir / "hermes-home"),
|
||||
"HERMES_CONFIG_PATH": str(state_dir / "hermes-home" / "config.yaml"),
|
||||
"HERMES_WEBUI_SKIP_ONBOARDING": "1",
|
||||
"HERMES_WEBUI_AGENT_DIR": str(agent_dir),
|
||||
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(workspace_dir),
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"no_proxy": "127.0.0.1,localhost",
|
||||
})
|
||||
|
||||
proc = log = log_path = playwright = browser = page = None
|
||||
errors: list[tuple[str, str]] = []
|
||||
exit_code = 1
|
||||
try:
|
||||
proc, log, log_path, base_url = _start_webui_server(repo_root, env, artifact_dir)
|
||||
playwright = sync_playwright().start()
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
context = browser.new_context(base_url=base_url)
|
||||
page = context.new_page()
|
||||
errors = _capture_page_errors(page)
|
||||
page.goto("/", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#msg", state="visible", timeout=15000)
|
||||
page.wait_for_function(
|
||||
"() => typeof window._autoScrollFollow === 'boolean'",
|
||||
timeout=15000,
|
||||
)
|
||||
session_id = _import_and_load(page)
|
||||
assert session_id, "imported session id missing"
|
||||
_expand_settled_worklog(page)
|
||||
hydrated = _activity_snapshot(page)
|
||||
_assert_hydrated(hydrated)
|
||||
print("OK historical load: ID-linked transcript hydrated to one Anchor Worklog")
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function(
|
||||
"text => (document.querySelector('#msgInner') || {}).innerText?.includes(text)",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
)
|
||||
_expand_settled_worklog(page)
|
||||
reloaded = _activity_snapshot(page)
|
||||
_assert_hydrated(reloaded)
|
||||
assert _semantic_snapshot(reloaded) == _semantic_snapshot(hydrated), {
|
||||
"hydrated": _semantic_snapshot(hydrated),
|
||||
"reloaded": _semantic_snapshot(reloaded),
|
||||
}
|
||||
print("OK hard reload: historical Anchor Worklog remains semantically identical")
|
||||
if errors:
|
||||
raise AssertionError(f"unexpected browser errors: {errors!r}")
|
||||
context.close()
|
||||
browser.close()
|
||||
browser = None
|
||||
print("\nHISTORICAL TRANSCRIPT HYDRATION GATE PASSED")
|
||||
exit_code = 0
|
||||
return 0
|
||||
except Exception as error:
|
||||
print(f"\nHISTORICAL TRANSCRIPT HYDRATION GATE FAILED: {error}", file=sys.stderr)
|
||||
try:
|
||||
if page is not None:
|
||||
page.screenshot(path=str(artifact_dir / "failure.png"), full_page=True)
|
||||
(artifact_dir / "snapshot.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"test_bite": TEST_BITE or None,
|
||||
"browser_errors": errors,
|
||||
"messages": page.evaluate(
|
||||
"""() => (S.messages || []).map((message) => ({
|
||||
role: message && message.role,
|
||||
content: message && message.content,
|
||||
tool_call_id: message && message.tool_call_id,
|
||||
tool_calls: Array.isArray(message && message.tool_calls)
|
||||
? message.tool_calls.map((call) => call && call.id)
|
||||
: null,
|
||||
has_anchor_scene: Boolean(message && message._anchor_activity_scene),
|
||||
}))"""
|
||||
),
|
||||
"dom": _activity_snapshot(page),
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as artifact_error:
|
||||
print(f"Could not capture browser artifacts: {artifact_error}", file=sys.stderr)
|
||||
print(f"Artifacts: {artifact_dir}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
if playwright is not None:
|
||||
playwright.stop()
|
||||
_terminate_process(proc)
|
||||
if log is not None:
|
||||
log.close()
|
||||
if proc is not None and proc.returncode not in (None, 0, -15):
|
||||
print(f"WebUI server exit code: {proc.returncode}", file=sys.stderr)
|
||||
if log_path is not None and log_path.exists() and proc is not None and proc.returncode not in (None, 0, -15):
|
||||
print(log_path.read_text(encoding="utf-8", errors="replace")[-4000:], file=sys.stderr)
|
||||
state_tmp.cleanup()
|
||||
if artifact_dir_owned and exit_code == 0:
|
||||
shutil.rmtree(artifact_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -231,6 +231,44 @@ def _reset_password_hash_cache():
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
|
||||
def _strip_leaked_webui_password_env() -> None:
|
||||
"""Remove a leaked HERMES_WEBUI_PASSWORD between tests (#7168 review).
|
||||
|
||||
bootstrap.py runs _load_repo_dotenv() at import time, which copies values
|
||||
from the developer's real repo .env straight into os.environ. When any
|
||||
test imports bootstrap mid-session (e.g. tests/test_bootstrap_foreground.py
|
||||
via its import_bootstrap fixture), a local .env containing
|
||||
HERMES_WEBUI_PASSWORD leaks into the process environment OUTSIDE
|
||||
monkeypatch's undo scope. Every later test then sees is_auth_enabled()
|
||||
True and no-handler cookie helpers raise spurious
|
||||
"build_profile_cookie requires a request handler" errors — exactly the
|
||||
#5588 failure shape, but sourced from the repo .env instead of the hash
|
||||
cache. Tests that legitimately enable auth set the var themselves AFTER
|
||||
this strip; an intentionally-empty value ("") is preserved so
|
||||
ctl.sh-style override semantics keep working.
|
||||
|
||||
HERMES_COMMAND gets the same treatment (#7168 re-gate round 7): a local
|
||||
.env carrying HERMES_COMMAND leaks past bootstrap imports and redirects
|
||||
gateway_restart._resolve_hermes_command() away from its mocked
|
||||
shutil.which result, failing every later active-profile-restart test
|
||||
with a machine-specific CLI path. Upstream code has no
|
||||
HERMES_COMMAND override, so stripping a leaked value restores exact
|
||||
upstream semantics.
|
||||
"""
|
||||
if os.environ.get("HERMES_WEBUI_PASSWORD") == "":
|
||||
pass # intentional empty override preserved for the password var
|
||||
else:
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
os.environ.pop("HERMES_COMMAND", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_leaked_webui_password():
|
||||
_strip_leaked_webui_password_env()
|
||||
yield
|
||||
_strip_leaked_webui_password_env()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _invalidate_providers_cache():
|
||||
"""Clear the /api/providers TTL cache around every test (#6010).
|
||||
@@ -651,6 +689,7 @@ def pytest_collection_modifyitems(config, items):
|
||||
'test_delivery_options_structure',
|
||||
'test_delivery_options_includes_common_platforms',
|
||||
'test_delivery_options_local_label',
|
||||
'test_delivery_options_survives_the_authority_module_move',
|
||||
# Skills endpoints (need tools.skills_tool module)
|
||||
'test_skills_list',
|
||||
'test_skills_list_has_required_fields',
|
||||
@@ -1192,6 +1231,24 @@ _AGENT_PATH_ENV_KEYS = ("HERMES_WEBUI_AGENT_DIR", "PYTHONPATH", "HERMES_WEBUI_PY
|
||||
_REAL_AGENT_ENV = {k: os.environ.get(k) for k in _AGENT_PATH_ENV_KEYS}
|
||||
_REAL_SYS_PATH = list(sys.path)
|
||||
|
||||
# Keep the Windows restart seams inert after the suite isolation snapshots.
|
||||
from api import updates as _updates
|
||||
|
||||
_real_windows_restart_spawn = _updates._windows_restart_spawn
|
||||
_real_windows_restart_exit = _updates._windows_restart_exit
|
||||
|
||||
|
||||
def _pytest_session_safe_windows_restart_spawn(_args, **_kwargs): # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
def _pytest_session_safe_windows_restart_exit(_code): # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
_updates._windows_restart_spawn = _pytest_session_safe_windows_restart_spawn
|
||||
_updates._windows_restart_exit = _pytest_session_safe_windows_restart_exit
|
||||
|
||||
|
||||
def _hermes_cli_is_healthy() -> bool:
|
||||
mod = sys.modules.get("hermes_cli")
|
||||
@@ -1246,6 +1303,41 @@ def _restore_hermes_cli_module():
|
||||
sys.path[:] = _REAL_SYS_PATH
|
||||
|
||||
|
||||
# ── hermes-agent API-drift compat: tools.approval._ApprovalEntry ─────────────
|
||||
# The core agent (commit 14791b4d4e, released in v2026.9.7) split tools/approval.py
|
||||
# into smart/human-wait/gateway-wait modules and dropped 43 back-compat facade
|
||||
# re-exports — including the module-level ``tools.approval._ApprovalEntry`` alias.
|
||||
# The class itself still exists, unchanged, at ``tools.approval_gateway_wait._ApprovalEntry``
|
||||
# (identical ``__init__(self, data)`` contract: threading.Event + data dict + an
|
||||
# auto-stamped request_id). Several webui gateway-approval tests reference
|
||||
# ``_ApprovalEntry`` through ``tools.approval`` (its pre-split public location):
|
||||
# on a box with the NEW agent installed the attribute is gone (AttributeError via
|
||||
# the module's PEP-562 ``__getattr__``); on an OLD agent it is still present. No
|
||||
# PRODUCTION webui code imports the dropped facade (verified: api/route_approvals.py
|
||||
# and api/streaming.py import only surviving names), so this is purely test-side
|
||||
# coupling to a moved internal symbol.
|
||||
#
|
||||
# This runs as an AUTOUSE fixture (not at conftest import time) because the agent
|
||||
# dir is only added to sys.path by the earlier autouse guards / server fixture —
|
||||
# ``tools.approval`` is not importable at conftest module load. The backfill is
|
||||
# idempotent and only aliases the class when the installed agent lacks it, using
|
||||
# the exact class object the agent uses internally. It keeps the tests working
|
||||
# across both the pre-split and post-split agent without touching ~22 call sites,
|
||||
# and is a no-op when tools.approval isn't importable (agent absent → tests skip).
|
||||
@pytest.fixture(autouse=True)
|
||||
def _backfill_approval_entry_facade():
|
||||
try:
|
||||
import tools.approval as _approval_mod
|
||||
except Exception:
|
||||
return # agent not importable here → agent-dependent tests skip anyway
|
||||
if getattr(_approval_mod, "_ApprovalEntry", None) is None:
|
||||
try:
|
||||
from tools.approval_gateway_wait import _ApprovalEntry as _entry_cls
|
||||
except Exception:
|
||||
return # unexpected layout → let tests skip/fail loudly, don't fake it
|
||||
_approval_mod._ApprovalEntry = _entry_cls
|
||||
|
||||
|
||||
# ── Per-test session cleanup ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"issue": 6611,
|
||||
"rows": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "same prompt"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "provider failed",
|
||||
"_error": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"source": "https://github.com/nesquena/hermes-webui/issues/6612",
|
||||
"symptom": "A stale WebUI tab overwrites a newer update-channel selection when the user changes any unrelated preference.",
|
||||
"sequence": [
|
||||
"Select Experimental and leave Tab A open; its settingsUpdateChannel control holds 'experimental'.",
|
||||
"In Tab B, select Stable and let that save complete.",
|
||||
"Without refreshing Tab A, toggle any unrelated preference there, such as TPS display or sidebar density.",
|
||||
"Tab A's generic Preferences autosave posts its full stale payload to /api/settings.",
|
||||
"save_settings() accepts the explicit allowed enum value, so the server silently switches back to Experimental."
|
||||
],
|
||||
"stale_autosave_payload": {
|
||||
"update_channel": "experimental",
|
||||
"show_tps": true
|
||||
},
|
||||
"expected_persisted_channel_after_stale_autosave": "stable",
|
||||
"explicit_channel_payloads": [
|
||||
{ "update_channel": "stable" },
|
||||
{ "update_channel": "experimental" }
|
||||
],
|
||||
"normalization_cases": [
|
||||
{ "input": { "update_channel": "nightly" }, "expected": "experimental", "note": "invalid enum value is ignored; persisted value unchanged" },
|
||||
{ "input": { "update_channel": "" }, "expected": "experimental", "note": "empty string is not a valid enum value; persisted value unchanged" },
|
||||
{ "input": {}, "expected": "experimental", "note": "absent key leaves persisted value unchanged" }
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
"Tab A may hold a stale channel value while Tab B changes the persisted channel.",
|
||||
"Changing an unrelated preference in Tab A does not alter the persisted channel.",
|
||||
"An explicit channel change still persists and immediately drives channel-scoped check/apply state.",
|
||||
"A regression test exercises the two-client stale-value sequence.",
|
||||
"Unknown/invalid/missing channel values leave the persisted channel unchanged."
|
||||
]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"source": "https://github.com/nesquena/hermes-webui/issues/7195#issuecomment-5381720224",
|
||||
"issue": "https://github.com/nesquena/hermes-webui/issues/7195",
|
||||
"base": "3b9c632a1fa339abcfd457973dcf10810640e760",
|
||||
"command": "python -m pytest tests/test_updates.py tests/test_update_checker.py tests/test_update_channels.py -q --tb=short -p no:cacheprovider",
|
||||
"launch": {
|
||||
"platform": "win32",
|
||||
"frozen": false,
|
||||
"argv_shape": ["C:\\Users\\david\\AppData\\Local\\hermes\\hermes-agent\\venv\\Lib\\site-packages\\pytest\\__main__.py", "tests/test_updates.py", "tests/test_update_checker.py", "tests/test_update_channels.py", "-q", "--tb=short", "-p", "no:cacheprovider"],
|
||||
"restart_payload_before_fix": ["C:\\Users\\david\\AppData\\Local\\hermes\\hermes-agent\\venv\\Scripts\\python.exe", "C:\\Users\\david\\AppData\\Local\\hermes\\hermes-agent\\venv\\Lib\\site-packages\\pytest\\__main__.py", "tests/test_updates.py", "tests/test_update_checker.py", "tests/test_update_channels.py", "-q", "--tb=short", "-p", "no:cacheprovider"],
|
||||
"observed_symptom": "Each pytest generation starts another pytest generation and its server fixtures, producing recursive children and leaked processes."
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,29 @@ Covers:
|
||||
3. Refresh path parity — configured aux routing still applies to the
|
||||
adaptive title refresh path.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Stub agent.auxiliary_client so it is importable in the test environment
|
||||
# (the real package lives in hermes-agent, which is not installed here).
|
||||
_agent_stub = types.ModuleType('agent')
|
||||
_aux_stub = types.ModuleType('agent.auxiliary_client')
|
||||
sys.modules.setdefault('agent', _agent_stub)
|
||||
sys.modules.setdefault('agent.auxiliary_client', _aux_stub)
|
||||
_agent_stub.auxiliary_client = _aux_stub
|
||||
import pytest
|
||||
|
||||
from tests._aux_client_helpers import auxiliary_client_modules, patch_tg_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _install_auxiliary_client_modules():
|
||||
"""Scope the synthetic hermes-agent modules to each test (#6630).
|
||||
|
||||
This file carried the same unscoped sys.modules.setdefault install as
|
||||
test_title_aux_routing.py, so it leaked the stub the same way.
|
||||
"""
|
||||
with auxiliary_client_modules():
|
||||
yield
|
||||
|
||||
|
||||
def _patch_tg_config(config_dict):
|
||||
"""Return a patch context manager that makes _get_auxiliary_task_config return config_dict."""
|
||||
return patch('agent.auxiliary_client._get_auxiliary_task_config', return_value=config_dict, create=True)
|
||||
return patch_tg_config(config_dict)
|
||||
|
||||
|
||||
def _make_provisional_session(user_text, assistant_text='Here is the answer.'):
|
||||
|
||||
@@ -173,6 +173,10 @@ def test_cleared_title_allows_initial_auto_generation(monkeypatch):
|
||||
"profile_env_for_background_worker",
|
||||
lambda *_args, **_kwargs: contextlib.nullcontext(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.state_sync.sync_session_title",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
streaming._run_background_title_update(
|
||||
session.session_id,
|
||||
|
||||
@@ -114,7 +114,7 @@ def test_project_context_content_is_redacted_in_memory_response(tmp_path, monkey
|
||||
|
||||
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(routes, "_memory_project_context_workspace", lambda _parsed: workspace)
|
||||
monkeypatch.setattr(routes, "_external_notes_sources_enabled", lambda: False)
|
||||
monkeypatch.setattr(routes, "_external_notes_sources_enabled", lambda _cfg: False)
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload)
|
||||
|
||||
payload = routes._handle_memory_read(object(), SimpleNamespace(query=""))
|
||||
|
||||
@@ -446,6 +446,144 @@ def test_branch_route_keeps_404_for_truly_missing_sessions(monkeypatch):
|
||||
assert cap["bad"] == ("Session not found", 404)
|
||||
|
||||
|
||||
def test_branch_route_slices_merged_display_view_not_raw_sidecar(monkeypatch):
|
||||
"""keep_count is an index into GET /api/session's merged display view.
|
||||
|
||||
Compression-lineage stitching and the state.db append-only merge can make
|
||||
the display view diverge from the raw sidecar in BOTH length and content.
|
||||
The branch handler must slice the same merged view the frontend indexed
|
||||
into; slicing the raw sidecar landed the cut rows too early, so forks from
|
||||
the final conclusion stopped mid tool-run and dropped that conclusion.
|
||||
"""
|
||||
handler = _FakeHandler()
|
||||
conclusion = {
|
||||
"role": "assistant",
|
||||
"content": "# CONCLUSION\n---\n> 🟢 final answer",
|
||||
"timestamp": 6.0,
|
||||
}
|
||||
# Raw sidecar: 6 rows (a replayed/duplicated tool-run segment survived a
|
||||
# compression continuation). The merged display view deduplicates one of
|
||||
# those rows, so the frontend sees 5 rows ending on the conclusion.
|
||||
raw_sidecar = [
|
||||
{"role": "user", "content": "question", "timestamp": 1.0},
|
||||
{"role": "assistant", "content": "checking", "tool_calls": [{"id": "t1", "name": "terminal", "arguments": {}}], "timestamp": 2.0},
|
||||
{"role": "tool", "tool_call_id": "t1", "content": '{"output":"ok"}', "timestamp": 3.0},
|
||||
{"role": "assistant", "content": "still checking", "tool_calls": [{"id": "t2", "name": "terminal", "arguments": {}}], "timestamp": 4.0},
|
||||
{"role": "tool", "tool_call_id": "t2", "content": '{"output":"ok2"}', "timestamp": 5.0},
|
||||
conclusion,
|
||||
]
|
||||
merged_view = [
|
||||
raw_sidecar[0],
|
||||
raw_sidecar[1],
|
||||
raw_sidecar[2],
|
||||
raw_sidecar[3],
|
||||
conclusion,
|
||||
]
|
||||
# Frontend clicked fork on the conclusion: index 4 in the merged view.
|
||||
keep_count = len(merged_view)
|
||||
|
||||
source = routes.Session(
|
||||
session_id="src-merged-1",
|
||||
title="Merged source",
|
||||
workspace=".",
|
||||
model="claude-sonnet",
|
||||
messages=list(raw_sidecar),
|
||||
context_messages=[],
|
||||
)
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"read_body",
|
||||
lambda _handler: {"session_id": "src-merged-1", "keep_count": keep_count},
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_session", lambda _sid, metadata_only=False: source)
|
||||
monkeypatch.setattr(routes, "_session_requires_cli_metadata_lookup", lambda _s: False)
|
||||
monkeypatch.setattr(routes, "_is_messaging_session_record", lambda _s: False)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_webui_sidecar_lineage_messages_for_display",
|
||||
lambda _s: list(raw_sidecar),
|
||||
)
|
||||
monkeypatch.setattr(routes, "get_state_db_session_messages", lambda *a, **k: [])
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"merge_session_messages_append_only",
|
||||
lambda side, state, **k: list(merged_view),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_merged_webui_lineage_messages_for_display",
|
||||
lambda _s, messages=None: list(messages if messages is not None else []),
|
||||
)
|
||||
monkeypatch.setattr(routes, "_evict_sessions_over_cap", lambda: None)
|
||||
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *a, **k: None)
|
||||
monkeypatch.setattr(routes.Session, "save", lambda self: None)
|
||||
cap = _capture_route(monkeypatch)
|
||||
routes.handle_post(handler, urlparse("/api/session/branch"))
|
||||
assert "bad" not in cap
|
||||
assert cap["status"] == 200
|
||||
forked = routes.SESSIONS[cap["ok"]["session_id"]]
|
||||
assert len(forked.messages) == keep_count
|
||||
assert forked.messages[-1]["role"] == "assistant"
|
||||
assert forked.messages[-1]["content"].startswith("# CONCLUSION"), (
|
||||
"Fork sliced the raw sidecar instead of the merged display view: "
|
||||
"the final conclusion is missing and the transcript ends mid tool-run"
|
||||
)
|
||||
routes.SESSIONS.pop(cap["ok"]["session_id"], None)
|
||||
|
||||
|
||||
def test_branch_route_uses_get_display_backstop_for_large_state_db(monkeypatch):
|
||||
"""Branch coordinates must use GET's bounded state.db display reader."""
|
||||
source = routes.Session(session_id="src-large", workspace=".", messages=[])
|
||||
seen = {}
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": source.session_id})
|
||||
monkeypatch.setattr(routes, "get_session", lambda *_a, **_k: source)
|
||||
monkeypatch.setattr(routes, "_session_requires_cli_metadata_lookup", lambda _s: False)
|
||||
monkeypatch.setattr(routes, "_is_messaging_session_record", lambda _s: False)
|
||||
monkeypatch.setattr(routes, "_webui_sidecar_lineage_messages_for_display", lambda _s: [])
|
||||
monkeypatch.setattr(routes, "_state_db_backstop_limit_for_display", lambda _s, before: 50000)
|
||||
def _state_rows(*_a, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return [{"role": "assistant", "content": "displayed conclusion"}]
|
||||
monkeypatch.setattr(routes, "get_state_db_session_messages", _state_rows)
|
||||
monkeypatch.setattr(routes, "merge_session_messages_append_only", lambda _side, state, **_k: state)
|
||||
monkeypatch.setattr(routes, "_merged_webui_lineage_messages_for_display", lambda _s, messages: messages)
|
||||
monkeypatch.setattr(routes, "_evict_sessions_over_cap", lambda: None)
|
||||
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(routes.Session, "save", lambda self: None)
|
||||
cap = _capture_route(monkeypatch)
|
||||
routes.handle_post(_FakeHandler(), urlparse("/api/session/branch"))
|
||||
assert cap["status"] == 200
|
||||
assert seen["limit"] == 50000
|
||||
routes.SESSIONS.pop(cap["ok"]["session_id"], None)
|
||||
|
||||
|
||||
def test_branch_route_messaging_empty_cli_does_not_copy_hidden_state_db(monkeypatch):
|
||||
"""Messaging-empty forks must not include state.db rows GET never displayed."""
|
||||
conclusion = {"role": "assistant", "content": "displayed conclusion"}
|
||||
source = routes.Session(session_id="src-msg-empty", workspace=".", messages=[conclusion])
|
||||
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
||||
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": source.session_id, "keep_count": 1})
|
||||
monkeypatch.setattr(routes, "get_session", lambda *_a, **_k: source)
|
||||
monkeypatch.setattr(routes, "_session_requires_cli_metadata_lookup", lambda _s: False)
|
||||
monkeypatch.setattr(routes, "_is_messaging_session_record", lambda _s: True)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: [])
|
||||
monkeypatch.setattr(routes, "_webui_sidecar_lineage_messages_for_display", lambda _s: [conclusion])
|
||||
monkeypatch.setattr(routes, "get_state_db_session_messages", lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("hidden state.db read")))
|
||||
monkeypatch.setattr(routes, "merge_session_messages_append_only", lambda side, state, **_k: side + state)
|
||||
monkeypatch.setattr(routes, "_merged_webui_lineage_messages_for_display", lambda _s, messages: messages)
|
||||
monkeypatch.setattr(routes, "_evict_sessions_over_cap", lambda: None)
|
||||
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(routes.Session, "save", lambda self: None)
|
||||
cap = _capture_route(monkeypatch)
|
||||
routes.handle_post(_FakeHandler(), urlparse("/api/session/branch"))
|
||||
assert cap["status"] == 200
|
||||
forked = routes.SESSIONS[cap["ok"]["session_id"]]
|
||||
assert forked.messages == [conclusion]
|
||||
routes.SESSIONS.pop(cap["ok"]["session_id"], None)
|
||||
|
||||
|
||||
# ── Session model ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_session_model_parent_session_id():
|
||||
@@ -468,7 +606,9 @@ def test_session_compact_includes_parent():
|
||||
# the top of compact() which pushed the parent_session_id field beyond a
|
||||
# 1500-char window — widen the scan to 3000 chars to cover the full
|
||||
# return-dict body without re-tightening every time compact() grows.
|
||||
compact_def_match = re.search(r"def compact\(self", src)
|
||||
# compact() accepts optional projection flags on separate lines; match the
|
||||
# method name and first parameter without pinning formatting.
|
||||
compact_def_match = re.search(r"def compact\(\s*self", src)
|
||||
assert compact_def_match, "Could not find compact() method"
|
||||
snippet = src[compact_def_match.start():compact_def_match.start() + 3000]
|
||||
assert "'parent_session_id'" in snippet, \
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user