Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f453dd27f3 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e | ||
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
fff3ba8d91 | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
afe2be9304 | ||
|
|
60c14b5db1 | ||
|
|
e3d6dd9509 | ||
|
|
91d05c982e | ||
|
|
85bbbd004d | ||
|
|
a8f61f2aeb | ||
|
|
d554c1819a | ||
|
|
920e7d58f0 | ||
|
|
0ce7c6c6b3 | ||
|
|
46e8f90c39 | ||
|
|
2d28f171e0 | ||
|
|
fdd8301796 | ||
|
|
c9ddc2ef8d | ||
|
|
b0d662b802 | ||
|
|
63ca0a1428 | ||
|
|
8196856d76 | ||
|
|
605ff00cc0 | ||
|
|
c9b1e1b04e | ||
|
|
a4466e4ca0 | ||
|
|
e13e381e3a | ||
|
|
19d33910d0 | ||
|
|
41480a3254 | ||
|
|
50f151edba | ||
|
|
0f108fe971 | ||
|
|
c8d6119e1a | ||
|
|
b2b7a2572b | ||
|
|
1ccf87401a | ||
|
|
50b1a17895 | ||
|
|
d536923c55 | ||
|
|
14a2e01ac5 | ||
|
|
a0d03f6947 | ||
|
|
478eeac3f7 | ||
|
|
799159457a | ||
|
|
f90650bdc9 | ||
|
|
6a41ec154c | ||
|
|
53f4c8187b | ||
|
|
675252090c | ||
|
|
e94db467e8 | ||
|
|
65dae79c8c | ||
|
|
e5d0334c8b | ||
|
|
9cc7b25fd1 | ||
|
|
2ce352a320 | ||
|
|
a6957268b9 | ||
|
|
8f42b96be1 | ||
|
|
8a9c058ddb | ||
|
|
5ef2c40f81 | ||
|
|
6b32fe7ddd | ||
|
|
02f38322fa | ||
|
|
f40b7abaf0 | ||
|
|
5fcbe5ff63 | ||
|
|
6ce504b1c9 | ||
|
|
31ebef825f | ||
|
|
68abbf156d | ||
|
|
73a8af7c8f | ||
|
|
2db00d4c59 | ||
|
|
847444d115 | ||
|
|
798e8eef55 | ||
|
|
35f791be50 | ||
|
|
e727979897 | ||
|
|
91025b733f | ||
|
|
34da86a96a | ||
|
|
30f9f51e2a | ||
|
|
713529f79f | ||
|
|
16d1728306 | ||
|
|
7a813995ba | ||
|
|
c86b2224ce | ||
|
|
11a782bc44 | ||
|
|
884a17ded7 | ||
|
|
745904d468 | ||
|
|
4258322acc | ||
|
|
2c544b8ab0 | ||
|
|
5122ee69f6 | ||
|
|
512199448e | ||
|
|
7a7b10f08a | ||
|
|
f7845291e7 | ||
|
|
689219405b | ||
|
|
f4e9de425a | ||
|
|
2bfe21eaff | ||
|
|
d4cdb96bab | ||
|
|
481ad48c57 | ||
|
|
c5f35145b4 | ||
|
|
3283c9b601 | ||
|
|
74be630e05 | ||
|
|
44f030f93b | ||
|
|
abce00f49e | ||
|
|
a61d27a92d | ||
|
|
123f1d1263 | ||
|
|
61c4337807 | ||
|
|
6e1338004c | ||
|
|
348152a7cb | ||
|
|
3ec34502ec | ||
|
|
d30de8656d | ||
|
|
9b9b8c7c06 | ||
|
|
93b82aa538 | ||
|
|
7f2049fa0a | ||
|
|
92444a7039 | ||
|
|
a02d7e10df | ||
|
|
8a3935ffa1 | ||
|
|
e48935929a | ||
|
|
1e82347e7d | ||
|
|
75ed3c4226 | ||
|
|
bade61ac34 | ||
|
|
f88559f856 | ||
|
|
22e4d24817 | ||
|
|
7edaa2df14 | ||
|
|
165feaa0d6 | ||
|
|
8c516c3c8d | ||
|
|
40bb0a4ef8 | ||
|
|
d96898a6aa | ||
|
|
b4e595e320 | ||
|
|
31c41fb2ff | ||
|
|
ab0f7b726a | ||
|
|
f72904ab53 | ||
|
|
4fc5f668de | ||
|
|
cedc340091 | ||
|
|
97cb30c927 | ||
|
|
ed41be3390 | ||
|
|
08816cfe63 | ||
|
|
01a0cde589 | ||
|
|
ed6742afe4 | ||
|
|
a6264df910 | ||
|
|
64e2e2eca6 | ||
|
|
1ccaf2c4f1 | ||
|
|
7686bb41e7 | ||
|
|
a940b4b8ea | ||
|
|
46afdeab59 | ||
|
|
d4a8aad050 | ||
|
|
0a6e95ae74 | ||
|
|
a6fc53e5cf | ||
|
|
c013daacda | ||
|
|
f5b1d377a4 | ||
|
|
bb1e406f3f | ||
|
|
10213ca8ed | ||
|
|
cbfccd8ccf | ||
|
|
c3c98caa31 | ||
|
|
ed60abd57c | ||
|
|
cab0d90530 | ||
|
|
d977600f9d | ||
|
|
c902c00101 | ||
|
|
aa6b48a068 | ||
|
|
50297d1496 | ||
|
|
d80f36a087 | ||
|
|
b6117c2d41 | ||
|
|
b0ee6935fe | ||
|
|
33538fde0c | ||
|
|
603919c8ff | ||
|
|
52df3adbf6 | ||
|
|
3eab11c639 | ||
|
|
2673f228bb | ||
|
|
53b8f6a418 | ||
|
|
0146e2b25d | ||
|
|
126e5a9600 | ||
|
|
55f50446a4 | ||
|
|
effa834e4e | ||
|
|
6d480b4131 | ||
|
|
ea38fc4ab5 | ||
|
|
72e893dc81 | ||
|
|
8dc7fdd7a0 | ||
|
|
795851c592 | ||
|
|
02f407241f | ||
|
|
d6f94b2b5b | ||
|
|
c5ee0670e9 | ||
|
|
87cd9e7b9d | ||
|
|
51a020bd22 | ||
|
|
34ff4d0629 | ||
|
|
06ba20406b | ||
|
|
a63b9b9828 | ||
|
|
72f1b68176 | ||
|
|
18c3ecf531 | ||
|
|
b6cb12e2da | ||
|
|
d99c2e5e45 | ||
|
|
1ab9d2f4af | ||
|
|
b406ce0e3e | ||
|
|
b92a04de81 | ||
|
|
78f0710ee0 | ||
|
|
87c2a8f000 | ||
|
|
f5533d262b | ||
|
|
896f276b7c | ||
|
|
af3c494697 | ||
|
|
77c1c8bee5 | ||
|
|
c452c25148 | ||
|
|
0db5c02722 | ||
|
|
4630695c17 | ||
|
|
f4ee440015 | ||
|
|
2dc47e8ecd | ||
|
|
a3fdfc2647 | ||
|
|
7d08786d28 | ||
|
|
2de9b40fc5 | ||
|
|
f7541e3795 | ||
|
|
d89fb906b0 | ||
|
|
963f1b7d85 | ||
|
|
5c045798ae | ||
|
|
22e557a533 | ||
|
|
03883b59b7 | ||
|
|
d679add380 | ||
|
|
f3c4bc1ad5 | ||
|
|
e8282ec8b1 | ||
|
|
eccf1b07ac | ||
|
|
354ecb56ea | ||
|
|
8c827b47e5 | ||
|
|
d6bbd02b4e | ||
|
|
e9203f0174 | ||
|
|
9ad7474901 | ||
|
|
98bf8cc25c | ||
|
|
be56892e61 | ||
|
|
f92ea07692 | ||
|
|
3294f28074 | ||
|
|
cc86b56092 | ||
|
|
37a2f35db5 | ||
|
|
1db3387ffa | ||
|
|
0f18380bf1 | ||
|
|
211a8738ea | ||
|
|
0503f35479 | ||
|
|
e0349ef6c4 | ||
|
|
2037583edb | ||
|
|
f78affd2b2 | ||
|
|
5e12d24599 | ||
|
|
cebc2a0166 | ||
|
|
a0c70f7bb6 | ||
|
|
9ba2b0fb0e | ||
|
|
0f867945bc | ||
|
|
0f5a6b4c9c | ||
|
|
45e4ff0a9e | ||
|
|
f5dab50e4d | ||
|
|
2479ddb9a6 | ||
|
|
45fef54c6e | ||
|
|
258583527e | ||
|
|
851f7f4dfc | ||
|
|
2e5fd4a3cf | ||
|
|
6d86d310ec | ||
|
|
e55dd99f62 | ||
|
|
7793934edf | ||
|
|
f49c6c4203 | ||
|
|
52a7d67cc4 | ||
|
|
c52340ecde | ||
|
|
7570f93dbf | ||
|
|
6d32ccf024 | ||
|
|
4233817e9f | ||
|
|
577732069f | ||
|
|
a738a0e151 | ||
|
|
42d6c77cfb | ||
|
|
bd9f53e8db | ||
|
|
2017d60f4c | ||
|
|
1e133ee15c | ||
|
|
9a40ed9afc | ||
|
|
9ce07b45f7 | ||
|
|
2fd90a6e81 | ||
|
|
7dd1125686 | ||
|
|
522c4fe82a | ||
|
|
c9b30dabae | ||
|
|
e9e92d03f2 | ||
|
|
da8e23068a | ||
|
|
aaee75e7fc | ||
|
|
8ebb21b16d | ||
|
|
0700ac81c6 | ||
|
|
015298f90a | ||
|
|
3c0e51f664 | ||
|
|
92f96831c4 | ||
|
|
033dcc37ba | ||
|
|
a144962e66 | ||
|
|
c683ad290c | ||
|
|
2968a173b1 | ||
|
|
5ff78da8e4 | ||
|
|
ffe534454a | ||
|
|
49c183ef9a | ||
|
|
906ce79a82 | ||
|
|
74f84d9492 | ||
|
|
c6bd418e30 | ||
|
|
8b79c06922 | ||
|
|
7a63a0c6ea | ||
|
|
83580cbf06 | ||
|
|
e636ca4715 | ||
|
|
c1926f6434 | ||
|
|
b78fe0244c | ||
|
|
d15f7860fc | ||
|
|
1660750b67 | ||
|
|
a1818579d9 | ||
|
|
f893330cfa | ||
|
|
16b16fd5ad | ||
|
|
227748912d | ||
|
|
9a1edf7d5a | ||
|
|
ab65ea7705 | ||
|
|
fde5030797 | ||
|
|
0173519183 | ||
|
|
c5d61bc427 | ||
|
|
f116d41295 | ||
|
|
4aedb9b839 | ||
|
|
ba24b3e959 | ||
|
|
5896d4c672 | ||
|
|
3c24e82e5e | ||
|
|
381c62d6b6 | ||
|
|
ad0139ba94 | ||
|
|
2abf9b000f | ||
|
|
bc957ef641 | ||
|
|
e3097682c1 | ||
|
|
63a7a79d2d | ||
|
|
789f32cd25 | ||
|
|
6f0357c2e8 | ||
|
|
7569144cc4 | ||
|
|
5b25b9b154 | ||
|
|
79dd6b44fe | ||
|
|
d96b68c794 | ||
|
|
e3ba358331 | ||
|
|
45c7ef49e2 | ||
|
|
6003258c5d | ||
|
|
a660b3825d | ||
|
|
5c214a2e6a | ||
|
|
c079a632ba | ||
|
|
c7de0da22d | ||
|
|
16133ff081 | ||
|
|
3a12221185 | ||
|
|
699653bf87 | ||
|
|
3d354080dd | ||
|
|
427145ccce | ||
|
|
654663bc3e | ||
|
|
75d965c32e | ||
|
|
37355974b4 | ||
|
|
380ad0b4bf | ||
|
|
13e747c6d9 | ||
|
|
500387d3fa | ||
|
|
55904a600a | ||
|
|
393a485f53 | ||
|
|
4fc1978df7 | ||
|
|
141a7560e7 | ||
|
|
f965c205d7 | ||
|
|
a138165408 | ||
|
|
467e6722a2 | ||
|
|
b5c1d392fb | ||
|
|
5df941a179 | ||
|
|
66728686b9 | ||
|
|
c05ee40db4 | ||
|
|
66166b2c2a | ||
|
|
c8a6534bec | ||
|
|
8afbb6120d | ||
|
|
9c080f522c | ||
|
|
d2a2d01072 | ||
|
|
fe297e2db9 | ||
|
|
f5bb41e46d | ||
|
|
0b0e323b20 | ||
|
|
d8cc3d7082 | ||
|
|
a8db3a2ed8 | ||
|
|
8dc874cbbf | ||
|
|
ea8d09e7f0 | ||
|
|
5c6211e63f | ||
|
|
8c8e24c4d0 | ||
|
|
d16f477d81 | ||
|
|
357723392d | ||
|
|
8e96a019a2 | ||
|
|
71f3331f54 | ||
|
|
e9effeeb87 | ||
|
|
e9b00757d5 | ||
|
|
891085ed61 | ||
|
|
b53cfbc906 | ||
|
|
045f42386d | ||
|
|
44e6e9b6dd | ||
|
|
c46560aeeb | ||
|
|
9593787482 | ||
|
|
44ba8ebb41 | ||
|
|
d25f805fa7 | ||
|
|
3cef7c39e3 | ||
|
|
ba18fe95c0 | ||
|
|
70015beb81 | ||
|
|
5a44d4519a | ||
|
|
fcbf5666e4 | ||
|
|
865c39bd86 | ||
|
|
c76e906b30 | ||
|
|
b56dec859d | ||
|
|
b36666db74 | ||
|
|
91763a286f | ||
|
|
bc6f288b0b | ||
|
|
9554c7c153 | ||
|
|
b047fc01c8 | ||
|
|
19d3945b83 | ||
|
|
27c325b681 | ||
|
|
1c014a6b8d | ||
|
|
0eca419853 | ||
|
|
7879622be0 | ||
|
|
e6e8527883 | ||
|
|
2a4c5a1de4 | ||
|
|
a458d5bd45 | ||
|
|
52400f5415 | ||
|
|
a1bf362f56 | ||
|
|
a86b07d42b | ||
|
|
393920440e | ||
|
|
950eb6e2ab | ||
|
|
309c0e5a76 | ||
|
|
b129aa53f0 | ||
|
|
a51cef43cd | ||
|
|
5322586121 | ||
|
|
7fcdf886ec | ||
|
|
efa51dcb55 | ||
|
|
8308fb87f3 | ||
|
|
e1bd38dd55 | ||
|
|
71d61bdbe8 | ||
|
|
3be6566eb0 | ||
|
|
f32376303b | ||
|
|
ea63052ffa | ||
|
|
f7d77ecc1c | ||
|
|
103b6e0552 | ||
|
|
62961322b9 | ||
|
|
9142fb8f79 | ||
|
|
a313bac5d2 | ||
|
|
03b05ad863 | ||
|
|
4ca65c120c | ||
|
|
400c20af51 | ||
|
|
3140e85c04 | ||
|
|
97be04a722 | ||
|
|
c17932c950 | ||
|
|
cc3db3a7f4 | ||
|
|
2ecdb5c797 | ||
|
|
21b171c3c4 | ||
|
|
f96545beb7 | ||
|
|
8560895e57 | ||
|
|
ebb4f041dd | ||
|
|
b5d30287a9 | ||
|
|
9081fb341c | ||
|
|
39cfedf776 | ||
|
|
3018a186c9 | ||
|
|
64c0c25c25 | ||
|
|
3897b8efc8 | ||
|
|
96a6c963dc | ||
|
|
373b98f316 | ||
|
|
83609a4608 | ||
|
|
2c1d7df764 | ||
|
|
df536b308f | ||
|
|
65f89d4c0b | ||
|
|
e78602c9d8 | ||
|
|
304e732650 | ||
|
|
ec9bc72cd6 | ||
|
|
927ff81307 | ||
|
|
4d57e88e83 | ||
|
|
664a15bf30 | ||
|
|
3513f60b4a | ||
|
|
f29d8b166b | ||
|
|
979b0aa130 | ||
|
|
71c8d8e966 | ||
|
|
d25c7c1aea | ||
|
|
e22357992b | ||
|
|
579f9be96b | ||
|
|
bf79c92018 | ||
|
|
a9f818d616 | ||
|
|
1a414db9cb | ||
|
|
c402963adf | ||
|
|
0390f27477 | ||
|
|
c39066808b | ||
|
|
2c12770047 | ||
|
|
c79a7a66b5 | ||
|
|
7e6de5d066 | ||
|
|
759c049b3e | ||
|
|
9ce23e28a2 | ||
|
|
7982b2dd81 | ||
|
|
7e5fd4d590 | ||
|
|
fedad55fb8 | ||
|
|
d050c9e683 | ||
|
|
409bc4e374 | ||
|
|
d1680bfdc3 | ||
|
|
fcdd0bd121 | ||
|
|
fd2a8b2546 | ||
|
|
77badad279 | ||
|
|
a00d989106 | ||
|
|
37ff218f33 | ||
|
|
4aa8859662 | ||
|
|
cd6289e19d | ||
|
|
fcf57abdf7 | ||
|
|
c7b00c3396 | ||
|
|
90dc6069f1 | ||
|
|
8862c31a66 | ||
|
|
77bf161055 | ||
|
|
45e7911d3d | ||
|
|
fa62264962 | ||
|
|
95f7c54335 | ||
|
|
6fb9127d60 | ||
|
|
76d68622bb | ||
|
|
8e89136477 | ||
|
|
34115130da | ||
|
|
dbccdeba41 | ||
|
|
f16e5916b9 | ||
|
|
1ae0627d92 | ||
|
|
9ffc6573df | ||
|
|
6721701f16 | ||
|
|
7350dc0ab8 | ||
|
|
01fa7ca59a | ||
|
|
663b3eea98 | ||
|
|
2b72c492ae | ||
|
|
e63b1be700 | ||
|
|
ee6e84cbd1 | ||
|
|
3573ba852f | ||
|
|
de44059c8e | ||
|
|
50fd7bd048 | ||
|
|
284cd9f585 | ||
|
|
e063fa694b | ||
|
|
0327012666 | ||
|
|
2e58449aec | ||
|
|
41ffe0ce1c | ||
|
|
81418d71b8 | ||
|
|
99b9cf1704 | ||
|
|
f1e8bfd7ac | ||
|
|
75e617bfb1 | ||
|
|
ee0591457b | ||
|
|
26811f0eb8 | ||
|
|
eafdb4efe2 | ||
|
|
802385c65c | ||
|
|
4160cb1f85 | ||
|
|
ec05643b6b | ||
|
|
984d9a2e63 | ||
|
|
65f22e21d9 | ||
|
|
36b05b637e | ||
|
|
0dfc581117 | ||
|
|
08a4efdceb | ||
|
|
92adfafc81 | ||
|
|
45326b377e | ||
|
|
889273aa85 | ||
|
|
206d182704 | ||
|
|
440f34080e | ||
|
|
6552566159 | ||
|
|
c3098a951e | ||
|
|
85c70338dc | ||
|
|
c9fa8f722b | ||
|
|
1dca285cd6 | ||
|
|
894b70ef62 | ||
|
|
80ea95db1c | ||
|
|
ed0b32e246 | ||
|
|
41037a3897 | ||
|
|
50c5fd8373 | ||
|
|
788d2abcb5 | ||
|
|
3ec432cd8b | ||
|
|
ef5bae7ca5 | ||
|
|
9be6422941 | ||
|
|
3d0b090a64 | ||
|
|
f972284dee | ||
|
|
a0bb195d4d | ||
|
|
038a2a472b | ||
|
|
f8141a6a91 | ||
|
|
c83f85745d | ||
|
|
674d2e34a2 | ||
|
|
7531065bdf | ||
|
|
0b922538f0 | ||
|
|
3166139f9e | ||
|
|
39cafc20c1 | ||
|
|
8b15c6d357 | ||
|
|
c869733069 | ||
|
|
7deb3efa88 | ||
|
|
f0e135c153 | ||
|
|
f6b965a97c | ||
|
|
0aa1b38a18 | ||
|
|
a22bdd9488 | ||
|
|
26e4a054d2 | ||
|
|
9f568e12cb | ||
|
|
a0b4d3715c | ||
|
|
e0a2a59957 | ||
|
|
738256238f | ||
|
|
d1820fb606 | ||
|
|
11274ce51b | ||
|
|
6fb15ddc9c | ||
|
|
15dcd6d637 | ||
|
|
42d262bc79 | ||
|
|
b977b6b02a | ||
|
|
d411764935 | ||
|
|
73c31803e9 | ||
|
|
d7a15d08fe | ||
|
|
da36172af3 | ||
|
|
3a99842011 | ||
|
|
d261a1c374 | ||
|
|
cf30b0dbc2 | ||
|
|
8ea813d8d8 | ||
|
|
a806726cb2 | ||
|
|
45519e9fc8 | ||
|
|
7746d7de98 | ||
|
|
3bec0d22b8 | ||
|
|
222fab4fb9 | ||
|
|
b27f3a0a7d | ||
|
|
3cbf0333ae | ||
|
|
954d2522ed | ||
|
|
fa973dd2df | ||
|
|
d827e460e0 | ||
|
|
3cd8791ce4 | ||
|
|
d1bf6245fd | ||
|
|
f083ceacf0 | ||
|
|
a43d395108 | ||
|
|
47d4d4f532 | ||
|
|
242665348d | ||
|
|
5544c23f05 | ||
|
|
3d5a94d818 | ||
|
|
aeaf7282f3 | ||
|
|
fc8aaff749 | ||
|
|
27e62ff768 | ||
|
|
c4b1a02ba5 | ||
|
|
f3fc8e557c | ||
|
|
b581756ffd | ||
|
|
3ae2495188 | ||
|
|
092d6a0c8f | ||
|
|
3c0f6f6cca | ||
|
|
41b341ed09 | ||
|
|
b5fd63bb93 | ||
|
|
5077ddd244 | ||
|
|
52990aaf37 | ||
|
|
133a785839 | ||
|
|
b1a0a7b21d | ||
|
|
a455e4688f | ||
|
|
60093e383d | ||
|
|
d5a1ef54f0 | ||
|
|
217daeddf1 | ||
|
|
f6b0afec9f | ||
|
|
11b0bb391d | ||
|
|
60eb993b15 | ||
|
|
6abb28e7ce | ||
|
|
bb1beed488 | ||
|
|
8537f75ab1 | ||
|
|
68a6ff6c00 | ||
|
|
c8e8d67560 | ||
|
|
349bee04ae | ||
|
|
3f51c23969 | ||
|
|
024515678e | ||
|
|
378a50eaf0 | ||
|
|
43135fe4b9 | ||
|
|
265ebf7df8 | ||
|
|
2b74f4552c | ||
|
|
911926cddb | ||
|
|
80c7337563 | ||
|
|
ad6b7468cd | ||
|
|
aa1b239e64 | ||
|
|
b76565f6f6 | ||
|
|
50e638f282 | ||
|
|
70e94a1aa8 | ||
|
|
373939ce95 | ||
|
|
f76203c227 | ||
|
|
a918bdb5fe | ||
|
|
3128d8cf66 | ||
|
|
9475f8bec4 | ||
|
|
bb3d89d5c4 | ||
|
|
4f291e1625 | ||
|
|
ddb691a3f3 | ||
|
|
800cc0b6ec | ||
|
|
9200b25224 | ||
|
|
0800ddeb4b | ||
|
|
2fed7bc479 | ||
|
|
bb0c9f76eb | ||
|
|
e27f5e8b9a | ||
|
|
f3aba63977 | ||
|
|
acfe55f958 | ||
|
|
8f45c7a4cd | ||
|
|
b996b379a7 | ||
|
|
b0db3638f0 | ||
|
|
bcfd509d35 | ||
|
|
d92d18a607 | ||
|
|
4dfa1ecd18 | ||
|
|
64e5a5f75e | ||
|
|
839c279da7 | ||
|
|
fcc6e7601d | ||
|
|
bebaeb7418 | ||
|
|
9d23eb32ad | ||
|
|
99239c8417 | ||
|
|
208a1a6ebc | ||
|
|
a11e7f9420 | ||
|
|
1224414a80 | ||
|
|
b7d6a2fb31 | ||
|
|
f38870a4c5 | ||
|
|
dbfde1ffc4 | ||
|
|
2cea7d1618 | ||
|
|
54337826bd | ||
|
|
7daa301075 | ||
|
|
b5bdf0a81f | ||
|
|
fa18e1c88e | ||
|
|
c71751cd06 | ||
|
|
578f67a352 | ||
|
|
9739b88af3 | ||
|
|
e04e55c35c | ||
|
|
2b7b698285 | ||
|
|
7ce8e6270a | ||
|
|
2b6fb2c3c8 | ||
|
|
0d969468a8 | ||
|
|
ceb707581e | ||
|
|
5e5f76076f | ||
|
|
0a34e73ab5 | ||
|
|
c53b7cabb9 |
@@ -1,10 +1,10 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security guidance
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/docs/security.md
|
||||
about: Review the security model before posting sensitive vulnerability details publicly.
|
||||
- name: Report a security vulnerability (private)
|
||||
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
|
||||
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
url: https://hermes-relay.dev/docs/
|
||||
about: Read setup, pairing, remote access, and troubleshooting docs.
|
||||
- name: Contributing guide
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Translation correction
|
||||
description: Report or propose a clearer translation for one locale.
|
||||
title: "[Translation]: "
|
||||
labels: ["translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
English defines the product meaning. Translation corrections are applied to the canonical locale catalog and credited through Git history.
|
||||
- type: input
|
||||
id: locale
|
||||
attributes:
|
||||
label: Language and locale
|
||||
placeholder: Spanish (es), Simplified Chinese (zh-Hans), etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Screen and current text
|
||||
description: Name the screen, resource key if known, and current translated wording.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: correction
|
||||
attributes:
|
||||
label: Suggested correction
|
||||
description: Include the corrected text and what the English source means in this context.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: proficiency
|
||||
attributes:
|
||||
label: Language familiarity
|
||||
options:
|
||||
- Native speaker
|
||||
- Fluent speaker
|
||||
- Professional translator
|
||||
- Learner or machine-assisted report
|
||||
- Prefer not to say
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: sensitive
|
||||
attributes:
|
||||
label: Sensitive meaning
|
||||
options:
|
||||
- label: This affects permissions, privacy, security, destructive actions, payments, or recovery instructions.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Optional screenshot, regional preference, or explanation of why the existing wording is misleading.
|
||||
@@ -12,10 +12,22 @@
|
||||
|
||||
-
|
||||
|
||||
## Lineage / contributor credit
|
||||
|
||||
<!--
|
||||
If this PR salvages or supersedes earlier work, link every source PR and name
|
||||
the original contributor(s). Preserve original commit authors where practical;
|
||||
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
||||
-->
|
||||
|
||||
- Source PR(s): N/A
|
||||
- Attribution preserved by: N/A
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Target branch is `dev` unless this is a release PR
|
||||
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
|
||||
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
|
||||
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
|
||||
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
|
||||
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
|
||||
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
|
||||
@@ -23,3 +35,4 @@
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing)
|
||||
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
|
||||
- [ ] Salvaged work links the source PR and preserves contributor authorship, or N/A
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# GitHub Copilot instructions — Hermes-Relay
|
||||
|
||||
This file exists so GitHub Copilot (which reads `.github/copilot-instructions.md`,
|
||||
not `AGENTS.md`) picks up the project's agent guidance.
|
||||
|
||||
**Read [AGENTS.md](../AGENTS.md) first — it is the single source of truth**
|
||||
for agent guidance: the entry point, the non-negotiables, and the public-repo
|
||||
writing hygiene. It links on to `CLAUDE.md` for the deep reference
|
||||
(architecture, upstream Hermes API, repository layout, per-language code style,
|
||||
the dev loop, and the Key Files map). Follow those; don't restate them here.
|
||||
|
||||
Quick non-negotiables (the full list and rationale are in `AGENTS.md`):
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default no-plugin connection
|
||||
must work against unmodified upstream hermes-agent; server-side needs go
|
||||
through upstream PRs or the optional relay plugin, never fork patches.
|
||||
- **Conventional Commits**, `main`/`dev` branching — feature branches off
|
||||
`dev`, `--no-ff` merges, tags cut from `main`.
|
||||
- **Android:** Jetpack Compose (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only; run `./gradlew lint` before pushing Kotlin.
|
||||
- **Public repo:** no personal names, no private infrastructure, no
|
||||
AI/assistant self-narration in committed prose.
|
||||
@@ -3,6 +3,7 @@ updates:
|
||||
# Gradle dependencies
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
@@ -24,10 +25,12 @@ updates:
|
||||
patterns:
|
||||
- "junit*"
|
||||
- "androidx.compose.ui:ui-test*"
|
||||
- "io.github.takahirom.roborazzi*"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
function classifyCiPaths(paths) {
|
||||
const forceAll = paths.some((path) => [
|
||||
'.github/workflows/ci-required.yml',
|
||||
'.github/scripts/classify-ci-paths.cjs',
|
||||
'.github/scripts/classify-ci-paths.test.cjs',
|
||||
].includes(path));
|
||||
const exact = (values) => paths.some((path) => values.includes(path));
|
||||
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
|
||||
|
||||
return {
|
||||
android: forceAll || under(['app/', 'relay-core/', 'relay-ui/', 'ui-preview/', 'quest/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
|
||||
'.github/workflows/play-preflight-android.yml',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
'.github/workflows/release-android.yml',
|
||||
]),
|
||||
desktop: forceAll || under(['desktop/']) || exact([
|
||||
'.github/workflows/ci-desktop.yml',
|
||||
]),
|
||||
plugin: forceAll || paths.some((path) => /^plugin\/[^/]+\.py$/.test(path)) ||
|
||||
under(['plugin/relay/', 'plugin/tools/', 'plugin/tests/', 'relay_server/', 'hermes_relay_bootstrap/']) || exact([
|
||||
'plugin/plugin.yaml', 'pyproject.toml', 'scripts/check-plugin-version-sync.py',
|
||||
'scripts/check-server-version-sync.py', 'scripts/bump-plugin-version.sh',
|
||||
'scripts/bump-server-version.sh', '.github/workflows/ci-plugin.yml',
|
||||
]),
|
||||
dashboard: forceAll || under(['plugin/dashboard/']) || exact([
|
||||
'.github/workflows/ci-dashboard.yml',
|
||||
]),
|
||||
contract: forceAll ||
|
||||
under(['app/src/main/kotlin/com/hermesandroid/relay/network/upstream/']) || exact([
|
||||
'scripts/check-upstream-route-contract.py', '.github/workflows/ci-contract.yml',
|
||||
]),
|
||||
docs: forceAll || under(['user-docs/']) || exact([
|
||||
'.github/workflows/docs.yml',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { classifyCiPaths };
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { classifyCiPaths } = require('./classify-ci-paths.cjs');
|
||||
|
||||
const none = {
|
||||
android: false,
|
||||
desktop: false,
|
||||
plugin: false,
|
||||
dashboard: false,
|
||||
contract: false,
|
||||
docs: false,
|
||||
};
|
||||
|
||||
assert.deepEqual(classifyCiPaths(['README.md']), none);
|
||||
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
|
||||
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
|
||||
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
|
||||
assert.deepEqual(
|
||||
classifyCiPaths(['app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt']),
|
||||
{ ...none, android: true, contract: true },
|
||||
);
|
||||
assert.deepEqual(classifyCiPaths(['.github/workflows/ci-required.yml']), {
|
||||
android: true,
|
||||
desktop: true,
|
||||
plugin: true,
|
||||
dashboard: true,
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
@@ -0,0 +1,100 @@
|
||||
# Hermes-Relay-Android — explicit public release approval
|
||||
#
|
||||
# Run from main only after the automated Play preflight passes and the release
|
||||
# PR has merged. Starting this workflow is the release approval. Creating the
|
||||
# stable tag triggers Play submission first, then GitHub publication.
|
||||
|
||||
name: Approve Android Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: approve-android-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Verify preflight and create release tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate approval request
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Approve Android Release must run from main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify this exact release tree passed Play preflight
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
|
||||
run: |
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified Play preflight proof: $ARTIFACT_NAME"
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
if gh api "/repos/${GITHUB_REPOSITORY}/git/ref/tags/android-v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Tag android-v${VERSION} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create approved Android release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh api --method POST "/repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="refs/tags/android-v${VERSION}" \
|
||||
-f sha="$GITHUB_SHA"
|
||||
|
||||
- name: Start the tag release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-android.yml \
|
||||
--ref=main \
|
||||
-f version="$VERSION"
|
||||
|
||||
- name: Approval summary
|
||||
run: |
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,51 +1,60 @@
|
||||
# Hermes-Relay — Android CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Android-affecting paths so Python-only changes don't spin up the JVM.
|
||||
# Runs directly on Android-affecting pushes to main/dev and is called by the
|
||||
# path-aware required-check workflow for relevant pull requests.
|
||||
#
|
||||
# Pipeline: lint -> build + test (parallel) -> upload artifacts
|
||||
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
|
||||
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
|
||||
# post-merge packaging. Main pushes keep APK artifacts.
|
||||
#
|
||||
# A release-build smoke (bundleRelease assembleRelease) runs on dev/main pushes
|
||||
# and on the dev→main release PR so release-only breakage (R8/minify rules,
|
||||
# resource shrinking, bundletool OOM) is caught BEFORE the android-v* tag,
|
||||
# instead of mid-release. It is debug-signed, so it needs no signing secrets.
|
||||
|
||||
name: CI — Android
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "relay-core/**"
|
||||
- "relay-ui/**"
|
||||
- "ui-preview/**"
|
||||
- "quest/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Lint — gate for build and test jobs
|
||||
# Android Lint
|
||||
# ──────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint (Android)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -55,28 +64,29 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# Prefer ktlintCheck if configured; fall back to Android lint
|
||||
- name: Run lint checks
|
||||
run: |
|
||||
if ./gradlew tasks --all 2>/dev/null | grep -q "ktlintCheck"; then
|
||||
echo "Running ktlintCheck..."
|
||||
./gradlew ktlintCheck
|
||||
else
|
||||
echo "ktlintCheck not found, falling back to Android lint..."
|
||||
./gradlew lint
|
||||
fi
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Build — assembleDebug + upload APK
|
||||
# Android Build — assembleDebug for PRs and main pushes
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (Android)
|
||||
needs: lint
|
||||
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -86,12 +96,15 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
with:
|
||||
name: debug-apk
|
||||
# Product flavors (googlePlay, sideload) nest APKs under
|
||||
@@ -109,7 +122,6 @@ jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
test:
|
||||
name: Test (Android)
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
@@ -118,7 +130,7 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -128,24 +140,81 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
# split: run the stable connection slice plus focused Chat/Voice state,
|
||||
# parser, layout, and accessibility regressions for the active release.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.data.AppLanguageTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
|
||||
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
|
||||
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
|
||||
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
|
||||
--console=plain
|
||||
|
||||
# Upload test reports even if tests fail, for debugging
|
||||
# Upload reports only for failures. Successful PR report uploads add
|
||||
# noticeable latency and are rarely inspected.
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Release build smoke — exercises the release variant the android-v* tag
|
||||
# build runs (./gradlew bundleRelease assembleRelease, both flavors), so
|
||||
# release-only breakage (R8/minify, resource shrinking, bundletool OOM) is
|
||||
# caught BEFORE the tag instead of mid-release. Debug-signed — no secrets,
|
||||
# so it also runs on fork PRs. Runs on dev/main pushes (early signal after
|
||||
# each merge) and on the dev→main release PR (hard pre-tag gate); skipped on
|
||||
# dev-targeted feature PRs to avoid re-running a ~12-min build per iteration.
|
||||
# ──────────────────────────────────────────────
|
||||
release-smoke:
|
||||
name: Release build smoke (Android)
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# Mirrors release-android.yml's build step. No keystore is provided here,
|
||||
# so app/build.gradle.kts falls back to debug signing — fine for a build
|
||||
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
|
||||
- name: Build release bundles + APKs (both flavors, debug-signed)
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
@@ -6,24 +6,19 @@
|
||||
# boot, no pip install, no model keys); see scripts/check-upstream-route-contract.py
|
||||
# for the design + tradeoff (catches renamed/removed routes; not runtime auth).
|
||||
#
|
||||
# PR/push runs check a pinned ref (non-flaky); the weekly schedule tracks
|
||||
# upstream `main` as a drift siren so a route rename surfaces on our clock.
|
||||
# Required-PR and direct push runs check a pinned ref (non-flaky); the weekly
|
||||
# schedule tracks upstream `main` as a drift siren.
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "scripts/check-upstream-route-contract.py"
|
||||
- ".github/workflows/ci-contract.yml"
|
||||
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "scripts/check-upstream-route-contract.py"
|
||||
- ".github/workflows/ci-contract.yml"
|
||||
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Mondays 06:00 UTC — upstream-drift siren (tracks main)
|
||||
workflow_dispatch:
|
||||
@@ -44,14 +39,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
# PIN this to a known-good NousResearch/hermes-agent commit to keep
|
||||
# PR/push runs non-flaky. Until pinned it tracks main (drift-exposed).
|
||||
DEFAULT_REF="main"
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
@@ -63,7 +59,7 @@ jobs:
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
@@ -29,10 +21,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
@@ -55,7 +47,11 @@ jobs:
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
# The suite imports the `plugin` package transitively: __init__ loads
|
||||
# android_tool/desktop_tool (`import requests`), and one test imports
|
||||
# `plugin.relay`, whose server.py needs `aiohttp` (+ pyyaml) from
|
||||
# relay_server/requirements.txt. fastapi+httpx cover plugin_api itself.
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -1,19 +1,20 @@
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
@@ -22,10 +23,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -34,9 +35,15 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Verify CLI and tray versions are synchronized
|
||||
run: npm run check:version-sync
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test typed stream rendering
|
||||
run: npm test
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
@@ -47,16 +54,8 @@ jobs:
|
||||
# prebuilt dist/ that references a source file that moved.
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: Upload dist/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-dist
|
||||
path: desktop/dist
|
||||
retention-days: 7
|
||||
|
||||
smoke-help:
|
||||
name: Smoke — --help + --version work on every target OS
|
||||
needs: typecheck-and-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -66,10 +65,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -94,10 +93,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -109,6 +108,12 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Check tray formatting
|
||||
run: npm run tray:fmt
|
||||
|
||||
- name: Lint tray shell
|
||||
run: npm run tray:lint
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
|
||||
@@ -1,47 +1,19 @@
|
||||
# Hermes-Relay — Plugin CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# plugin-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
# Runs directly on plugin-affecting pushes to main/dev and is called by the
|
||||
# path-aware required-check workflow for relevant pull requests.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused plugin tests
|
||||
# Pipeline: syntax-check and focused plugin tests run concurrently.
|
||||
|
||||
name: CI — Plugin
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/*.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
@@ -69,18 +41,16 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/config.py
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
@@ -103,7 +73,6 @@ jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Plugin tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
@@ -112,7 +81,7 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
@@ -121,7 +90,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
# Editable install pulls the full runtime dependency set from
|
||||
# pyproject.toml (requests, aiohttp, segno, httpx, websocket-client,
|
||||
# pyyaml). test_native_layout_imports imports the whole relay module
|
||||
# chain in a clean subprocess, so the minimal relay_server/requirements
|
||||
# set is not enough on its own.
|
||||
pip install -e .
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
@@ -129,4 +103,7 @@ jobs:
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
|
||||
@@ -1,49 +1,137 @@
|
||||
# Required-checks sentinel — always runs on every PR + push to main/dev so
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
# Path-aware required CI for pull requests targeting main or dev.
|
||||
#
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-plugin.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
# any PR that didn't touch the protected paths was blocked from merging,
|
||||
# even with all the relevant gates green. We were admin-overriding every
|
||||
# desktop-only PR. Same for relay-touching PRs (the protection rule named
|
||||
# `Relay Check (Python)` didn't even match any actual job — broken since
|
||||
# day one).
|
||||
#
|
||||
# This sentinel + claude-review become the only required checks. The
|
||||
# path-filtered workflows still run when relevant and surface their
|
||||
# results on the PR — visible, clickable, but advisory rather than
|
||||
# blocking. Reviewers (human + claude-review) eyeball them. This is the
|
||||
# standard pattern for monorepos with path-filtered CI.
|
||||
#
|
||||
# Trade-off acknowledged: a broken Android build on an Android-touching
|
||||
# PR could merge if the reviewer ignores the failing CI badge. Mitigation:
|
||||
# claude-review reads CI conclusions in its review prompt + the project's
|
||||
# release-merge cadence catches issues before they reach a tag. If a
|
||||
# stricter gate is later wanted, fold it into this workflow as a job that
|
||||
# fans out to the path-filtered work — but the simplest version (just an
|
||||
# `echo`) is what's needed to make branch protection useful again today.
|
||||
# The change detector selects the existing surface workflows, which are exposed
|
||||
# through workflow_call. The final job keeps one stable branch-protection check
|
||||
# while ensuring that every relevant build or test actually completed.
|
||||
|
||||
name: Required checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR. Doesn't matter much for
|
||||
# a 5-second job, but matches every other workflow's concurrency shape.
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect affected surfaces
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
plugin: ${{ steps.filter.outputs.plugin }}
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
|
||||
);
|
||||
const outputs = classifyCiPaths(paths);
|
||||
|
||||
for (const [surface, affected] of Object.entries(outputs)) {
|
||||
core.setOutput(surface, affected ? 'true' : 'false');
|
||||
}
|
||||
core.notice(`Changed paths: ${paths.join(', ')}`);
|
||||
core.notice(`Selected checks: ${Object.entries(outputs).filter(([, value]) => value).map(([key]) => key).join(', ') || 'none'}`);
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
uses: ./.github/workflows/ci-desktop.yml
|
||||
|
||||
plugin:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.plugin == 'true'
|
||||
uses: ./.github/workflows/ci-plugin.yml
|
||||
|
||||
dashboard:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dashboard == 'true'
|
||||
uses: ./.github/workflows/ci-dashboard.yml
|
||||
|
||||
contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.contract == 'true'
|
||||
uses: ./.github/workflows/ci-contract.yml
|
||||
|
||||
docs:
|
||||
name: Build public docs
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: user-docs
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
CONTRACT_RESULT: ${{ needs.contract.result }}
|
||||
DOCS_RESULT: ${{ needs.docs.result }}
|
||||
steps:
|
||||
- name: OK
|
||||
run: echo "Required-checks sentinel — see ci-required.yml header for context."
|
||||
- name: Require every selected check to pass
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
case "$result" in
|
||||
success|skipped) ;;
|
||||
*) failed=1 ;;
|
||||
esac
|
||||
done
|
||||
exit "$failed"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Website CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "website/**"
|
||||
- "assets/screenshots/02_chat.png"
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "website/**"
|
||||
- "assets/screenshots/02_chat.png"
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -1,90 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
# Any dev -> main PR is, by the branching model, the aggregate release PR
|
||||
# (main only ever receives release merges from dev). Detect it by base+head
|
||||
# alone — a title-format match (e.g. "release:") is fragile and silently
|
||||
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
|
||||
# Bot-authored PRs such as Dependabot do not receive the same secret
|
||||
# surface as human-authored PRs, and Claude Code rejects bot actors unless
|
||||
# explicitly allow-listed. Keep the required check green with a no-op and
|
||||
# rely on the dependency CI/status checks for those PRs.
|
||||
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
|
||||
|
||||
steps:
|
||||
- name: Skip aggregate release PR review
|
||||
if: env.IS_RELEASE_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Skip bot-authored PR review
|
||||
if: env.IS_BOT_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for bot-authored PR."
|
||||
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Depth 2 includes the pull_request merge commit's first parent, which
|
||||
# lets the next step detect whether this PR changes the workflow file.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Claude review workflow changes
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
id: changed-workflow
|
||||
shell: bash
|
||||
run: |
|
||||
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
|
||||
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
|
||||
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip Claude review workflow self-change
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
|
||||
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# Hermes-Relay — Docs Deployment
|
||||
#
|
||||
# Builds VitePress docs and deploys to GitHub Pages.
|
||||
# Triggers on pushes to main that change user-docs/ content,
|
||||
# or manually via workflow_dispatch.
|
||||
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'user-docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to label again"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label from title prefix and issue area
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number,
|
||||
});
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const haystack = `${title}\n${body}`;
|
||||
const labels = [];
|
||||
|
||||
if (title.startsWith('[bug]')) labels.push('bug');
|
||||
else if (title.startsWith('[feature]') || title.startsWith('[feat]')) labels.push('enhancement');
|
||||
else if (title.startsWith('[docs]')) labels.push('documentation');
|
||||
|
||||
if (/\b(cli|desktop|terminal|daemon|pty|hermes-relay (install|binary|tray))\b/.test(haystack)) labels.push('area:cli');
|
||||
else if (/\b(dashboard|plugin ui|react)\b/.test(haystack)) labels.push('area:dashboard');
|
||||
else if (/\b(relay|plugin|aiohttp|python|pairing|voice (transcribe|synthesize)|bridge (endpoint|route))\b/.test(haystack)) labels.push('area:plugin');
|
||||
else if (/\b(readme|user-?docs|documentation)\b/.test(haystack)) labels.push('area:docs');
|
||||
else if (/\b(android|app|compose|apk|phone|samsung|gradle|chat|voice|notification|sphere|keystore)\b/.test(haystack)) labels.push('area:android');
|
||||
|
||||
if (!labels.length) {
|
||||
core.info('No deterministic label matched; leaving the issue for maintainer triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number, labels,
|
||||
});
|
||||
core.info(`Applied labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.warning(`Could not apply ${labels.join(', ')}: ${error.message}`);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Deploy legacy docs redirects
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: legacy-docs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build redirect artifact
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build redirect-only site
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_file="legacy-pages-redirect/redirect.html"
|
||||
privacy_file="website/public/privacy.html"
|
||||
output_dir="legacy-pages-redirect/_site"
|
||||
rm -rf "$output_dir"
|
||||
mkdir -p \
|
||||
"$output_dir/guide/getting-started" \
|
||||
"$output_dir/privacy" \
|
||||
"$output_dir/reference/relay-server" \
|
||||
"$output_dir/architecture"
|
||||
for target in \
|
||||
index.html \
|
||||
404.html \
|
||||
guide/getting-started.html \
|
||||
guide/getting-started/index.html \
|
||||
reference/relay-server.html \
|
||||
reference/relay-server/index.html \
|
||||
architecture/connection-security.html; do
|
||||
cp "$source_file" "$output_dir/$target"
|
||||
done
|
||||
cp "$privacy_file" "$output_dir/privacy.html"
|
||||
cp "$privacy_file" "$output_dir/privacy/index.html"
|
||||
touch "$output_dir/.nojekyll"
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 10
|
||||
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
|
||||
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
|
||||
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload redirect artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: legacy-pages-redirect/_site
|
||||
|
||||
deploy:
|
||||
name: Deploy redirect shim
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Play Store Listing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "assets/screenshots/**"
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "assets/screenshots/**"
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_listing:
|
||||
description: "Publish Play Store listing metadata after validation"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Listing Assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install image tooling
|
||||
run: python -m pip install --upgrade rich Pillow
|
||||
|
||||
- name: Validate screenshots and listing metadata
|
||||
run: python scripts/screenshots.py validate
|
||||
|
||||
publish-listing:
|
||||
name: Publish Listing Metadata
|
||||
needs: validate
|
||||
# Auto-publish the listing when its assets change on `main` (the release
|
||||
# branch; the path filters above already scope this to screenshot/graphic/
|
||||
# text changes). `dev` pushes and PRs validate only. A manual dispatch with
|
||||
# `publish_listing` still works as an on-demand republish.
|
||||
if: >-
|
||||
${{ (github.event_name == 'workflow_dispatch' && inputs.publish_listing)
|
||||
|| (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Write Play service account
|
||||
id: sa
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
# Skip gracefully (no red CI) when the secret isn't configured — e.g.
|
||||
# an auto-publish push to main before the service account is set up.
|
||||
echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not configured — skipping listing publish."
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish Play Store listing
|
||||
if: ${{ steps.sa.outputs.configured == 'true' }}
|
||||
run: ./gradlew publishGooglePlayReleaseListing
|
||||
|
||||
- name: Remove Play service account
|
||||
if: always()
|
||||
run: rm -f play-service-account.json
|
||||
@@ -0,0 +1,155 @@
|
||||
# Hermes-Relay-Android — private Google Play preflight
|
||||
#
|
||||
# Run manually from the final dev or untagged main tree before creating
|
||||
# android-v*. The job
|
||||
# builds the same signed release artifacts, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. A successful upload is the automated
|
||||
# Play gate while no public GitHub Release or sideload APK exists. Console-only
|
||||
# pre-review and pre-launch reports are informational and do not block release.
|
||||
|
||||
name: Play Preflight — Android
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Android version to preflight (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: play-preflight-android
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Build and upload private Play draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Require final release branch and matching version
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/dev" ] && [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Run Play preflight from dev or untagged main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require Play and release-signing secrets
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "::error::HERMES_KEYSTORE_BASE64 is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Validate release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py --live
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build final release artifacts
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan final release DEX
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
- name: Upload private production draft to Play
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle \
|
||||
--track=production \
|
||||
--release-status=draft \
|
||||
--resolution-strategy=ignore \
|
||||
--release-name="Hermes-Relay ${{ steps.metadata.outputs.version }}"
|
||||
|
||||
- name: Record successful preflight for the exact commit
|
||||
run: |
|
||||
mkdir -p app/build/reports
|
||||
cat > app/build/reports/play-preflight.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.metadata.outputs.version }}",
|
||||
"versionCode": "${{ steps.metadata.outputs.version_code }}",
|
||||
"commit": "$GITHUB_SHA",
|
||||
"tree": "${{ steps.metadata.outputs.tree }}",
|
||||
"track": "production",
|
||||
"status": "draft"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Upload preflight proof
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
|
||||
path: app/build/reports/play-preflight.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Preflight summary
|
||||
run: |
|
||||
echo "## Play preflight ready" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Version: **${{ steps.metadata.outputs.version }}** (code ${{ steps.metadata.outputs.version_code }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Commit: \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Approve Android Release** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -3,7 +3,7 @@
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release Android
|
||||
|
||||
@@ -11,9 +11,20 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "android-v*"
|
||||
# Approve Android Release creates its tag with GITHUB_TOKEN, whose tag event
|
||||
# does not recursively start workflows. It dispatches the current workflow
|
||||
# definition from main, while every job checks out the immutable tag. Manual
|
||||
# tag pushes continue to use the push trigger.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -22,12 +33,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||
REF_VERSION="$DISPATCHED_VERSION"
|
||||
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
fi
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -41,16 +71,52 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Android release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
- name: Verify public privacy policy URLs
|
||||
run: python3 scripts/check-privacy-policy.py --live
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
echo "Run Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Play preflight proof found: $ARTIFACT_NAME"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -60,9 +126,15 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
@@ -80,7 +152,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -90,6 +164,8 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
@@ -115,6 +191,12 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
- name: List produced artifacts (debug aid)
|
||||
run: |
|
||||
echo "=== APK outputs ==="
|
||||
@@ -126,12 +208,40 @@ jobs:
|
||||
# Flavor dimension adds an extra path segment to the AGP output layout.
|
||||
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
|
||||
# (note the concatenated camelCase — AGP path quirk, documented but
|
||||
# different between APK and AAB). The globs below match both flavors.
|
||||
# different between APK and AAB). Checksums cover EXACTLY the files
|
||||
# attached to the GitHub Release (see the 2-asset policy on the
|
||||
# release step below) so SHA256SUMS.txt matches the assets 1:1.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/*/release/*.apk bundle/*Release/*.aab > SHA256SUMS.txt
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Require Play credentials for stable release
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for stable Android releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Submit preflighted Play draft to production review
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew promoteGooglePlayReleaseArtifact \
|
||||
--update=production \
|
||||
--version-code=${{ needs.validate.outputs.version_code }} \
|
||||
--release-status=completed \
|
||||
--release-name="Hermes-Relay ${{ needs.validate.outputs.version }}"
|
||||
|
||||
# Public distribution happens only after Play accepts the production
|
||||
# submission above. This keeps a Play-detected release blocker from
|
||||
# appearing after the sideload APK is already public.
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
@@ -139,50 +249,13 @@ jobs:
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
# `hermes-relay-<version>-sideload-release.apk` for the full
|
||||
# Phase 3 / Tier 3/4/6 feature set; the
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` is what gets
|
||||
# uploaded to Play Console. APK twin of the googlePlay flavor
|
||||
# and AAB twin of the sideload flavor are included for parity
|
||||
# (useful for diff tooling, not primary downloads).
|
||||
# Deliberate 2-asset policy (#144): attach ONLY the installable
|
||||
# sideload APK and Play AAB, plus checksums covering those files.
|
||||
files: |
|
||||
app/build/outputs/apk/*/release/*.apk
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/apk/sideload/release/*.apk
|
||||
app/build/outputs/bundle/googlePlayRelease/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
# Runs only when the Play service-account secret is configured AND this is
|
||||
# a stable tag (prereleases — versions containing a dash — are skipped so
|
||||
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
|
||||
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
|
||||
# and persists across steps in this job, so the AAB is release-signed.
|
||||
#
|
||||
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
|
||||
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
|
||||
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
|
||||
# build lands on the Production track as a DRAFT: CI does the upload, a human
|
||||
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle --track=production
|
||||
rm -f play-service-account.json
|
||||
|
||||
- name: Play upload skipped (no secret)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
|
||||
run: |
|
||||
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
|
||||
"GitHub Release artifacts are still published; upload to Play manually" \
|
||||
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
|
||||
@@ -1,24 +1,77 @@
|
||||
name: Release CLI
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-v*']
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
validate-release:
|
||||
name: Validate tag, branch, and version metadata
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Extract and validate tag version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#desktop-v}"
|
||||
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
|
||||
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
npm run check:version-sync -- --expect "$version"
|
||||
if ! grep -Fq "## [$version]" ../CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has no release heading for $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
shell: bash
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate-release
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -35,6 +88,9 @@ jobs:
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test CLI
|
||||
run: npm test
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
@@ -100,14 +156,15 @@ jobs:
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
needs: validate-release
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -130,38 +187,44 @@ jobs:
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Check and lint tray shell
|
||||
run: npm run tray:fmt && npm run tray:lint
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Install NSIS
|
||||
run: choco install nsis --yes --no-progress
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Normalize installer asset name
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
|
||||
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
|
||||
if (-not $installer) { throw 'NSIS installer was not produced' }
|
||||
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
# $HOME is a read-only automatic variable in PowerShell (names are
|
||||
# case-insensitive), so use a distinct scratch name; only the
|
||||
# $env:HOME / $env:USERPROFILE environment vars are writable.
|
||||
$smokeHome = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $smokeHome | Out-Null
|
||||
$env:USERPROFILE = $smokeHome
|
||||
$env:HOME = $smokeHome
|
||||
$env:HERMES_RELAY_CLI_PATH = (Resolve-Path dist/bin/hermes-relay-win-x64.exe).Path
|
||||
$proc = Start-Process -FilePath tray/target/release/hermes-relay-tray.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
$proc.Refresh()
|
||||
if ($proc.MainWindowHandle -ne 0) { throw 'menu-only systray created an application window' }
|
||||
$traySize = (Get-Item tray/target/release/hermes-relay-tray.exe).Length
|
||||
if ($traySize -gt 5242880) { throw "tray executable exceeds 5 MiB: $traySize bytes" }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "tray launch smoke OK pid=$($proc.Id)"
|
||||
Write-Host "menu-only tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-windows-tray-installer
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
name: cli-windows-installer
|
||||
path: desktop/dist/tray/hermes-relay-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
@@ -173,13 +236,13 @@ jobs:
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract CLI version
|
||||
- name: Extract Desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
@@ -194,7 +257,7 @@ jobs:
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
|
||||
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
@@ -207,7 +270,7 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
@@ -218,5 +281,5 @@ jobs:
|
||||
release-assets/cli-binaries/hermes-relay-linux-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
name: Release Plugin
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-v*"
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Plugin release
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync
|
||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
- name: Verify Server version sync and changelog
|
||||
run: |
|
||||
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
@@ -68,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
@@ -100,8 +117,8 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
|
||||
tag_name: plugin-v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body_path: release_notes_rendered.md
|
||||
|
||||
@@ -31,6 +31,10 @@ local.properties
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Scratch / working directory (local pet packs, generated test assets, etc.)
|
||||
/tmp/
|
||||
/build-*.log
|
||||
*.jks
|
||||
*.keystore
|
||||
/captures
|
||||
@@ -73,6 +77,9 @@ hermes-agent-fork/
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Per-issue dev-loop brief generated by scripts/start-issue.sh into each worktree
|
||||
ISSUE-BRIEF.md
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
|
||||
@@ -84,5 +91,7 @@ keystore.properties
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
# Legacy generated desktop tray assets may remain after upgrading a worktree.
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
|
||||
@@ -5,27 +5,68 @@ coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
This file is the provider-neutral canonical agent context. Read it before
|
||||
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
|
||||
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
|
||||
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
## Branch contract
|
||||
|
||||
| Contract item | Canonical source or target |
|
||||
|---|---|
|
||||
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
|
||||
| Release branch | `main`; release history and hotfix integration only |
|
||||
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
|
||||
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
|
||||
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
|
||||
| Hotfix base | The immutable production tag for the affected surface |
|
||||
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
|
||||
|
||||
Feature completion means merged and verified on `dev`; it does not mean
|
||||
released. A release train is separate work owned by a Forge release
|
||||
issue/session: reconcile only the affected surface version and notes on `dev`,
|
||||
open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
|
||||
surface artifacts, deploy or roll out, and verify the live result. Never create
|
||||
a staging branch.
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection —
|
||||
chat via the API server, standard voice via the Hermes dashboard — must work
|
||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
|
||||
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
|
||||
and Vanilla Hermes voice. The API server is an optional automatic fallback and
|
||||
advanced headless-compatibility surface; Relay adds optional extensions. This
|
||||
path must work against unmodified upstream hermes-agent. Server-side needs go
|
||||
through upstream PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
|
||||
Version bumps happen only during release preparation on `dev`, and production
|
||||
tags are cut only from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
|
||||
everywhere, structured `logging` (no `print`). **Desktop CLI (Node ≥21):**
|
||||
zero runtime deps, strict TS + ES modules, ship compiled `dist/`. Full
|
||||
per-language style and the dev loop live in CLAUDE.md → "Code Style".
|
||||
|
||||
## Review guidelines
|
||||
|
||||
- Report only actionable correctness, security, compatibility, or release-risk
|
||||
findings; avoid stylistic preferences unless they violate a documented rule.
|
||||
- Treat the vanilla Hermes upstream boundary as release-critical. Flag any
|
||||
default-path dependency on relay-only or fork-only server behavior.
|
||||
- Check that changes preserve public-repo writing hygiene and do not expose
|
||||
secrets, private infrastructure, or personal information.
|
||||
- Use the affected surface's CI result as evidence, but do not imply Android UI
|
||||
or device behavior was proven without an explicit on-device verification.
|
||||
- Prioritize findings that warrant holding the merge. State the impacted path
|
||||
and the concrete failure mode.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
|
||||
@@ -6,20 +6,421 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
|
||||
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
|
||||
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
|
||||
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes connections now use the Dashboard/Gateway as their standard surface.** Chat, sessions, Manage, and voice share one upstream sign-in; the API server is an optional automatic fallback or headless compatibility path, while Relay remains optional for power features.
|
||||
- **Connection management and onboarding now explain each path clearly.** Nearby and remote dashboard setup, Tailscale and custom ports, Relay pairing, startup preference, route details, and security posture are presented in dedicated flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Server default consistently displays Hermes' pinned active profile.** Chat, session drawers, agent details, settings, voice, diagnostics, and profile inspection now use the active profile identity while preserving server-default routing semantics.
|
||||
- **Discovered connections show useful host identity.** Successful local dashboard probes resolve and retain a hostname without overwriting a user-supplied connection label.
|
||||
|
||||
## [1.4.8] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
|
||||
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
|
||||
|
||||
## [1.4.7] - 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
|
||||
|
||||
## [Android 1.4.6] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile display order and visibility are customizable per connection.** The profile manager can reorder every profile, including Server default, selectively hide inactive profiles, restore hidden active profiles, and reset the saved presentation without changing server configuration.
|
||||
- **Agent icons can come from the phone or paired host.** The profile manager offers the Android document picker and can import conventional host files such as `avatar.png` or `profile.jpg`, storing a per-connection/profile copy on the phone.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile image import reports host compatibility accurately.** Android now distinguishes an older Relay without the optional avatar endpoint from a profile that genuinely has no conventional image, and presents the system file picker as a clear fallback.
|
||||
- **Server-default chats use one profile session scope.** Android resolves the Server default row through Hermes' sticky active profile before Gateway create/resume and dashboard session operations, so the drawer, transcript, writes, and agent no longer split across different profile databases when the dashboard was launched under another profile.
|
||||
|
||||
## [Plugin 1.4.2] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile avatars are available to paired clients.** Relay discovers conventional direct-child profile images such as `avatar.png` and `profile.jpg`, validates their type, size, and profile boundary, and serves them through an authenticated profile route.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay follows Hermes' sticky active profile.** The advertised Server default identity, model, SOUL, profile metadata, and avatar now come from the profile selected by Hermes' `active_profile` marker instead of always describing the root profile.
|
||||
|
||||
## [1.4.5] - 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Running Android chats survive session switching.** On the upstream Gateway path, opening another chat, profile, draft, or Thread now detaches the visible stream without interrupting Hermes. Each running session keeps its own durable UI checkpoint, reconnects the shared event socket across route loss, and reattaches through `session.activate`/`session.resume` when selected again. SSE fallback remains intentionally single-stream and cancels on navigation.
|
||||
- **Expired Gateway prompts no longer remain actionable.** Android collapses matching secret and sudo cards when Hermes emits their expiry events, recognizes late expired responses, and is ready for an upstream session-scoped approval-expiry contract without guessing the server timeout.
|
||||
- **Provider wait notices stay transient.** Canonical Hermes provider-wait, reconnect, and continuation notices now use Chat's live status line instead of accumulating in the assistant reasoning transcript.
|
||||
|
||||
## [0.4.0-alpha.2] - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop chat can use Relay typed streaming over WSS.** The opt-in `--relay-chat` mode sends `chat.send`, renders typed `stream.event` v1 assistant/tool/artifact/memory/skill/error lifecycles, de-duplicates reconnect events, and preserves the existing gateway chat path as the default.
|
||||
- **Pending computer-use grants are manageable from the CLI.** `hermes-relay grants` lists and interactively approves or rejects local grant-bridge requests, with explicit `approve`, `reject`, and JSON forms for scripts.
|
||||
- **Desktop use has a durable CLI control plane.** `hermes-relay computer-use` persists enablement, reports daemon and grant state, and cancels active task-scoped grants through the local daemon bridge.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The optional Windows systray is a native context menu for the CLI.** The WebView dashboard, embedded terminals, overlays, chat, sessions, plugins, voice, and settings windows were removed. The sub-megabyte tray now invokes the single installed CLI for TUI, pairing, daemon control, grants, audit, and logs.
|
||||
- **Systray daemon controls are state- and privilege-aware.** The menu cross-checks PID liveness, identifies User versus Administrator daemons, disables invalid lifecycle actions, shows pending-grant counts and version metadata, toggles sign-in startup, and requests UAC only for an explicit elevated daemon start or restart.
|
||||
- **Systray desktop-use controls preserve safety across restart and elevation.** The menu enables or disables the persistent capability, displays active grant mode and expiry, raises a native pending-approval alert, supports immediate cancellation, and warns while Administrator input authority is active.
|
||||
- **CLI and tray releases use one synchronized version contract.** A single npm lifecycle keeps package, compiled CLI, Cargo, and installer metadata aligned; local verification and tag CI reject drift, off-main release tags, and untested CLI changes before publishing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compiled CLI diagnostics report the physical executable.** `hermes-relay doctor` no longer mistakes Bun's virtual embedded path for the installed binary, so PATH and install-directory checks describe the executable that actually launched.
|
||||
|
||||
## [1.4.4] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adds AI-assisted Spanish.** A repeatable translation harness and freshness checks keep catalogs structurally complete while tracking fluent review separately.
|
||||
- **Diagnostics exposes the Relay contract.** A manual refresh reports the installed plugin version, protocol version, capability count, profile enablement state, and last-check time; shared issue reports include sanitized Android and device metadata.
|
||||
- **What’s New links to complete release history.** The polished modal now provides direct access to every bundled version, with large-text screenshot coverage.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile operations stay inside the selected Hermes profile.** Session list, history, rename, delete, and in-flight recovery no longer fall through to the default database after a scoped failure; optimistic writes roll back and repeated recovery failures stop cleanly.
|
||||
|
||||
## [1.4.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Language switching is available inside the app.** Settings → Appearance now offers System default, English, and Simplified Chinese, stays synchronized with Android's per-app language setting, and persists the choice on Android 12 and lower.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Release builds reject unsupported collection APIs.** CI now scans Kotlin sources and final minified APK bytecode for Java 21 list endpoint calls that can crash on Android versions before API 35.
|
||||
|
||||
## [1.4.2] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Android now supports Simplified Chinese.** Chat, Manage, Voice, connection setup, settings, diagnostics, notifications, accessibility labels, and both product flavors follow the device language, with Android per-app language discovery on supported versions.
|
||||
- **Localization is contributor-ready.** CI enforces resource, plural, and format-argument parity; translated README and VitePress entry points establish a repeatable path for adding languages without duplicating fast-moving technical references.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Connection scan and queued-message counts use proper plurals.** Count formatting no longer depends on English-only suffix arguments and cannot fail when a locale needs a different plural structure.
|
||||
|
||||
## [1.4.1] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Background work is visible in Standard Chat.** A live process strip opens a mobile process sheet with running or recent state, output, elapsed time, Stop, and Dismiss controls. It remains compatible with older Hermes servers that do not expose process details.
|
||||
- **Background work has a clearer Chat home.** Realtime work appears as a titled task card with working, waiting, delivery, and completion states, queued work, and an expandable tool timeline.
|
||||
- **Multi-image messages open as galleries.** Adjacent images render in a compact grid and open at the selected image in a swipeable viewer while preserving sensitive-media reveal and original-file actions.
|
||||
- **Voice gains commands and presets.** Spoken commands can stop speech, cancel background work, pause or resume listening, repeat a result, or start Standard voice chat. Hands-free, Low latency, Careful tools, and Quiet presets tune existing interaction settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Streaming Chat content stays steadier and more readable.** Settled prose and headings adopt final Markdown styling during generation, wide tables scroll with readable columns, the thinking indicator respects system motion and TalkBack settings, and the jump-to-bottom control counts unread messages.
|
||||
- **Offline Demo mode no longer starts Voice.** The mic action now explains locally that a Hermes connection is required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An in-flight Chat turn survives reopening the app.** Session-backed replies restore partial text, live reasoning, lifecycle status, tool/subagent cards, background-task state, and unanswered approval or clarification cards. Current Hermes gateways reattach to the same running turn; older or finished sessions reconcile from history without duplicating the prompt or losing the final answer.
|
||||
- **Realtime Agent delivery is protected.** Hermes results use exact provider speech where supported, delivery validation, generation-safe confirmation, and a single relay-TTS fallback if the provider closes or rejects delivery. Voice commands no longer leave synthetic cancellation turns or mute a later background answer.
|
||||
- **Standard Chat receives background-process completions automatically.** When Hermes completes detached work and starts a follow-up turn on the originating Gateway session, Android shows the unsolicited assistant stream in the open conversation and reconciles history after a cold reconnect. The synthetic process prompt is rendered as a compact process notice rather than a user-authored message.
|
||||
|
||||
## [1.4.0] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Android model pickers can refresh the server catalog.** Chat's model sheet and Manage's main/profile model dialogs now expose upstream's explicit **Refresh Models** action, so dynamic/custom provider model lists can be reloaded on demand without making every picker open probe providers.
|
||||
- **Server-backed session cleanup plumbing.** The dashboard client now supports single-session export, the upstream `/api/sessions/prune` route with a mandatory dry-run preview before destructive apply, plus soft archive/restore helpers and an `archived` session-list filter for the Manage surface.
|
||||
- **Notification triggers MVP.** Settings → Notifications now has explicit opt-in proactive rules for the Notification companion: match by app package plus optional title/text filters, post a safe local "Ask Hermes?" prompt, show the latest trigger activity, and pause everything instantly with a kill switch.
|
||||
- **Android bridge: multi-device targeting.** The relay can keep multiple Android bridge clients connected at once, route commands by `device` selector (`phone`, `pixel`, `fold`, `boox`, `note`, `notemax`, `tablet`, or device ID), expose `/bridge/devices` and `/bridge/select-active`, and advertise an optional `device` argument on the `android_*` tool schemas.
|
||||
- **Voice: a second long request gets queued, not refused.** Ask for another long task while one is already running in the background and it's now queued (up to three) and starts automatically when the current one finishes — with a short spoken transition. The task card shows "+N queued", and cancelling the current task clears the queue.
|
||||
- **Voice: background answers start speaking sooner and can never be silently lost.** The spoken summary now streams as it's generated (it used to be held until fully complete — a noticeable dead gap, then the whole answer at once). Delivery is verified two ways: the summary must actually reflect the answer's content (not just avoid known filler phrases), and if no spoken delivery lands within 30 seconds the answer is posted as text instead of vanishing.
|
||||
- **Voice: tap the finished-task card to hear the answer again.** After a background task's card settles to "finished," tapping it replays the delivered answer. The card also now shows in the compact voice view (it previously existed only in the full-screen layout), a "Drafting the answer…" status appears as the reply is being composed, and leaving voice mode with a task still running leaves a note in chat so the work stays visible.
|
||||
- **Voice: quick questions answered while a background task runs.** Realtime voice used to refuse *any* second request while a long task ran in the background — even a two-second lookup. A quick second ask is now answered inline on a side session (within the same few-second window that decides backgrounding); anything that turns out to be long still gets the "a task is already running" answer, and the running task is never disturbed.
|
||||
- **Voice: the background-task card no longer vanishes mid-answer.** The card used to disappear the instant the spoken answer started (exactly when the waveform returned), reading as the task being lost. It now settles to a "Background task finished." state, lingers for a few seconds while the answer plays, then dismisses itself — and its ✕ during that settled state just dismisses the card instead of sending a cancel.
|
||||
- **Voice: the "Thinking" pill no longer spins forever.** The server streams its drafting text as an internal pseudo-tool that never reports completion, and the app rendered it as a live tool pill — which then ran indefinitely in both chat and the voice overlay. Internal tool events no longer become pills (their text still feeds the thinking trace).
|
||||
- **Voice: background-task answers can't be lost to a stray cancel.** Tapping cancel/stop after a background task had already finished used to mark the finished run "cancelled" — losing the answer that was about to be spoken. Cancel now only cancels a run that's actually still running; stopping the current speech works as before.
|
||||
- **Voice: no more spoken run IDs or phantom queue state.** The realtime voice model no longer reads 32-character run IDs aloud after starting a background task (identifiers stay out of everything it's asked to speak), no longer claims a request was queued unless the relay accepted it, and a completed task's answer is spoken directly — deferral filler like "one moment while I look that up" in place of a finished result now triggers the fallback that speaks the real answer.
|
||||
- **Voice: finished-task answers keep the realtime voice.** A completed background task's answer is now spoken by the same realtime voice you've been talking to — read word for word from the authoritative Hermes answer — instead of switching to the standard TTS voice mid-conversation. The answer always lands: if the realtime model goes off-script or the provider connection drops, standard TTS speaks it, and if you start talking mid-delivery it's posted as text instead of interrupting you. The "When the answer is ready" setting keeps its four modes (Exact / Summary / Notify / Show), now explained behind an info icon in Voice Settings.
|
||||
- **Voice: realtime models refreshed.** OpenAI realtime now defaults to `gpt-realtime-2.1` (with the cheaper `gpt-realtime-2.1-mini` selectable), the versioned `grok-voice-think-fast-1.0` pin is available alongside xAI's `grok-voice-latest` alias, and session logs record which model the provider *actually* served — so provider-side alias moves no longer happen invisibly.
|
||||
- **Voice: session logs clean up after themselves.** Realtime voice session logs are swept after 14 days by default (`realtime_voice.run_retention_days`, 0 disables), and the per-response TTS audio capture is now opt-in debug tooling (`debug_audio_tap`) instead of an always-on multi-MB tap.
|
||||
- **Voice: one-command delivery health report.** `python -m plugin.relay.realtime_agent.report` summarizes recent voice deliveries — how many were spoken by the realtime voice vs fell back to TTS or text, and why — for quick health checks after live testing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bootstrap compatibility layer slimmed to true gaps.** The optional compatibility hook no longer injects session CRUD/messages or the legacy skills list — current Hermes serves those natively; it now covers only surfaces with no native replacement yet (session search, memory, legacy skill detail/toggle, config, available-models, and the slash-command middleware). Older pre-session-API Hermes builds degrade to the standard completions/runs chat paths.
|
||||
- **Dependency floor: aiohttp ≥ 3.14.1.** Raised from 3.9 across plugin requirements and package metadata to the patched line covering the 2026 aiohttp security advisories.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Realtime voice recovers after background route loss.** A recorded turn now waits for a relay-confirmed resumed socket, retains unacknowledged follow-up PCM for replay, and reports transport rejection instead of sitting on a dead persistent connection. Resume handshakes are coalesced, and the relay requires a valid resume claim before replacing the active phone socket, so a slower stale connection cannot detach background-result delivery. Long-lived sessions start their bounded retry window when the route actually drops instead of at voice-mode entry, and a bare socket open cannot reset it. Late callbacks from a retired session are ignored. Exiting voice mode clears its detached reconnect and confirmation state before another session opens; rejected or unacknowledged cancels no longer leave an undismissable background-task pill. Provider transcription no longer impersonates active microphone capture, Stop settles the local turn even when the route is gone, and provisional `Listening...` / `Still working...` rows cannot remain stuck in chat.
|
||||
- **xAI exact background answers bypass model deferral.** Non-structured **Exact** deliveries now use xAI's provider-native forced speech event, preserving the selected realtime voice and normal assistant history while speaking the authoritative Hermes answer without asking the model to follow a read-verbatim prompt. Structured results and summary modes still use natural model summarization, and the validator plus standard-TTS fallback remain as safety nets.
|
||||
- **Background voice handoffs no longer repeat themselves.** If the realtime provider already spoke an acknowledgement before calling Hermes, promotion keeps that first line and suppresses the redundant "running in the background" follow-up; silent tool calls still receive the configured spoken handoff. Provider protocols that report both response creation and output-item creation now also produce one client `response.started` event instead of two.
|
||||
- **Realtime voice model and voice picks now apply to the next session.** Voice Settings persists the selected Realtime Agent model and voice per connection/profile and sends both when opening a session, so choosing a pinned model immediately controls the next session instead of requiring **Save realtime agent** to rewrite the relay config. The active voice UI reflects the override, changing it retires any prewarmed session, and the choice survives an app restart.
|
||||
- **Fresh realtime sessions emit one ready event.** Android's required `session.start` acknowledgement no longer causes the relay to send a second `voice.session.ready`, avoiding duplicate event IDs and duplicate session-ready telemetry on every new voice conversation.
|
||||
- **Relay media can no longer serve credential files.** `/media/by-path` now always blocks paths that resolve into credential or system locations (`~/.hermes/.env`, `auth.json`, `config.yaml`, OAuth/MCP token stores, `pairing/`, `~/.ssh`, and similar) even in the default permissive mode — mirroring upstream Hermes' media-delivery hardening — so a prompt-injected `MEDIA:` marker can't deliver live secrets to a paired phone. Symlinks are resolved before the check, and the relay's own QR-signing secret and session-token store are covered too.
|
||||
- **Long agent turns no longer die or duplicate at the transport.** Gateway chat (Android and the desktop CLI) now gives `prompt.submit` up to 30 minutes to acknowledge — matching upstream desktop and the server's own turn ceiling — instead of short generic RPC timeouts that could falsely fall back to SSE (duplicating the turn on Android) or kill a legitimately long deep-reasoning turn. Turn liveness is governed by idle-progress watchdogs (no events at all for a stretch), never a hard cap while output is still streaming.
|
||||
- **Manage → Models keeps providers that still need keys.** Newer Hermes hides unconfigured providers from the model catalog unless a management UI opts in; Android Manage now opts in and keeps rendering greyed provider rows with their key-setup guidance on both old and new servers. In-chat model picking is unchanged (configured providers only).
|
||||
- **Phone-local context actually reaches the server on fallback chat paths.** The sessions/runs streaming payloads carried voice-intent traces, card dispatches, and attachments in fields the server never reads — silently dropping them. That context now rides channels the server actually consumes (a per-turn context digest, real history fields where they exist, inline images on the completions path), and any attachment with no supported channel is reported instead of silently discarded.
|
||||
- **Relay plugin works under the native `hermes plugins install` path.** The plugin's runtime imports assumed the repo's editable layout, so upstream's native installer (which loads plugins under its own package namespace) broke `hermes relay start` and `hermes pair` with `ModuleNotFoundError: No module named 'plugin'`. All runtime imports are now package-relative, the dashboard module boots correctly when the upstream web server loads it standalone, and `hermes relay doctor` now exercises the real import chain so this class of breakage can't pass doctor again. (#165)
|
||||
- **Installer handles modern venv layouts.** `install.sh` now autodetects the classic venv, uv-managed `.venv`, and containerized layouts — and everything it generates (the systemd unit and all four command shims) points at the interpreter it actually detected instead of a hardcoded classic path. On immutable container images it steers to the native install path with a clear message instead of dying mid-run. (#165)
|
||||
- **Doctor catches dashboard URLs pointed at the wrong Hermes surface.** `hermes relay doctor` now distinguishes the dashboard/Manage surface from an API-server/headless backend URL and tells operators to use `hermes dashboard` when a configured dashboard URL is actually pointing at `hermes serve` / the API server.
|
||||
- **Doctor and installer catch stale duplicate plugin copies.** The gateway plugin loader picks a discovered plugin by manifest name, so a second directory declaring `name: hermes-relay` (a leftover backup copy or a stray extra install) could win and make the gateway load stale code — silently ignoring every later deploy. `hermes relay doctor` now warns when more than one directory under the plugins dir declares the same plugin name, and `install.sh` removes any such duplicate so only the canonical plugin symlink remains.
|
||||
- **Crash-safety on Android 14 and earlier.** Built against SDK 35, Kotlin's `removeFirst()`/`removeLast()` resolve to the new Java `List` methods that don't exist below Android 15, crashing older devices. All such calls in the app are now `removeAt(...)`, and Tink (pulled in by encrypted storage) is pinned ahead of the transitive version whose `HybridConfig` tripped the same Google Play pre-launch check.
|
||||
- **No crash when a relay address is malformed.** A corrupt or hand-edited pairing address with an invalid host could crash the app the moment it opened the relay connection (the connection is built on a background thread, so the error escaped uncaught). A bad relay address is now handled as a normal connection failure — shown as disconnected with a "re-pair to refresh" note — instead of crashing. The same guard now also covers the relay's media, session, and voice HTTP calls. (relay half of #131)
|
||||
- **Voice: cleaner error recovery.** A failed or timed-out voice turn no longer shows the same error twice (the top overlay banner and a duplicate bottom banner) and can now be **dismissed**, not just retried — so a stuck error state can't block the screen.
|
||||
- **Voice: fallback-spoken answers no longer play into a frozen overlay.** When an answer is delivered by the standard TTS fallback (or replayed from the finished-task card), the voice screen now shows the waveform and the answer text while it speaks — previously it sat on "Thinking" with no visuals even though audio was playing.
|
||||
- **Voice: a quiet realtime session no longer dies with a raw provider error.** xAI ends a realtime conversation after 900 seconds of inactivity, and no keepalive traffic resets that timer — so a voice session left open through a long background task (or simply left open) died with a raw provider error. That provider timeout is now treated as routine expiry: the session ends cleanly with no error banner, and your next voice turn transparently opens a fresh provider conversation that picks up from the same durable Hermes chat session.
|
||||
- **No crash when a malformed server address reaches a chat send.** The three streaming chat paths built their HTTP request before any error handling, so a corrupt or hand-edited API URL could throw instead of failing the turn gracefully. They now surface "Invalid server address — edit the connection's API URL or re-pair" through the normal in-chat error channel (closes the remaining #131 crash-class gap).
|
||||
- **Demo mode: typing a message now gets an honest reply.** Sending a message in the offline demo used to do nothing (the composer silently ignored it, reading as broken). The demo now echoes your message and answers with a short notice explaining it's an offline sample, pointing at the Connect action to chat for real.
|
||||
- **Voice: realtime conversations reliably reach your chat history.** Turns the realtime voice model answers directly (without calling Hermes) are folded into the chat session on your next message — but on the default gateway connection that hand-off could be deferred indefinitely, so the agent never learned what was said in voice. The turn that carries them now routes so the sync actually lands. Synced voice turns also render cleanly when a chat reloads: a quiet "Realtime Agent" chip instead of a raw provenance footnote, and no more duplicated voice exchange after the sync.
|
||||
|
||||
## [1.3.0] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings: edit your server's voice engine.** Voice settings now has a **Server voice config** section that reads and writes the host's text-to-speech and speech-to-text settings — provider, voice, model, language, and per-provider options — over the dashboard, the same config the official desktop app edits. It includes an **ElevenLabs voice picker** that lists the voices available on your server's ElevenLabs key (and tells you when no key is set). Works on the no-plugin (Standard) path; sign in to Manage to use it.
|
||||
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
|
||||
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session), and `relay queue` (list — or `--clear` / `--cancel <id>` — the messages your agent queued for an offline phone; on the relay host).
|
||||
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
|
||||
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
|
||||
- **Animated "thinking" indicator.** While a reply streams, the in-bubble working indicator can now be a small dot-matrix animation instead of the three dots. Pick a motion (Wave, Pulse, Bounce, Sparkle) and a color (match-text or a brand accent) in Chat settings, with a live preview. It follows light/dark and your app theme, and goes static when animations are turned off.
|
||||
- **Proactive messages from the agent to your phone.** Your Hermes agent can reach out to the paired phone on its own — via `send_message target=phone` or a cron `deliver=phone`. Messages surface as a system notification, collect in a dedicated Hermes inbox, and can be injected into the active chat to continue the conversation (selected per message). Off by default and gated on pairing: nothing is pushed unless you enable it on the server (`PHONE_ENABLED`) and opt in on the phone ("Let Hermes message me"). Delivered over the existing relay connection through the upstream platform-plugin API (no fork).
|
||||
- **Reply to your agent's messages (two-way).** A proactive message is now a conversation, not a one-way ping: reply straight from the notification (inline Reply) or from the Hermes inbox, and your answer goes back to the agent and continues the same thread. The phone behaves like any other Hermes messaging platform — the reply arrives as an inbound message the agent processes and answers. Rides the same paired relay connection; no extra setup beyond the proactive opt-in above. If your phone is offline when the agent answers, the message is queued and delivered when you reconnect — not lost.
|
||||
- **Pick your font.** A Font picker in Appearance sets the app-wide typeface — **Inter** (the new default), **Nunito**, or your **system** font — each previewed in its own face and applied instantly across the app, no restart. Code and timestamps stay monospaced. (Bundled faces are SIL OFL.)
|
||||
- **Quick Controls in Settings.** A Quick Controls card at the top of Settings groups the switches you flip most often — **Persistent connection** and **Turn-complete alerts** — so they're one tap from the Settings root instead of buried in a sub-screen.
|
||||
- **Connections: a cleaner list and a tabbed detail.** Settings → Connections is now a scannable list — each server shows an **Active** badge and an at-a-glance capability summary (API · Dashboard · Voice · Relay) — and tapping a server opens a focused detail screen with **Overview**, **Routes**, **Advanced**, and **Security** tabs. Rename / re-pair / revoke / remove moved into the detail's **⋮** menu, and **relay sessions** (review and revoke the phones paired with that server) get a clear home under Security.
|
||||
- **Keep connected through deep sleep (sideload).** When **Persistent connection** is on, Settings offers a one-tap "Allow unrestricted battery" prompt so the connection survives Android's deep-sleep (Doze) — without it, the OS pauses background networking after the screen's been off a while even with a foreground service. (Sideload only; Google Play restricts this permission.)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reporting a diagnostic now files the right kind of issue.** The Report button on a diagnostics entry used to turn routine log lines into "[Bug]" GitHub issues with an empty template. Now informational entries first ask "what were you expecting to happen?" and file as a "[Diagnostic]" question, error entries keep the direct bug flow, and every report carries the connection mode you were actually on instead of a placeholder line. (#155, #154, #146)
|
||||
- **Simpler release downloads.** Each Android release on GitHub now attaches just two files — the tap-to-install sideload APK and the Play Store upload bundle — plus checksums, with the release notes leading with the one file most people want. The extra "parity/testing" artifacts are gone from the release page (still reproducible from the tag via CI). (#144)
|
||||
- **Clearer, snappier voice capture and playback.** Voice now engages the device's echo-cancellation and noise-suppression while recording (matching the desktop's microphone setup), and requests audio focus before the first reply so the opening words aren't clipped on a cold start. Listening timing also matches the official desktop: auto-stop ~1.25s after you stop speaking (was 3s), give up after 12s with no speech, and cap a turn at 60s.
|
||||
- **Refreshed chat look.** Message bubbles are wider and denser, each assistant turn shows a small Hermes avatar to its left (once per group), and code blocks are richer — a language label, a copy button, and a clearer inset so fenced code and inline `code` no longer blend into the bubble.
|
||||
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
- **Persistent connection (was "keep chat connected").** The background keep-alive and its notification are reframed from a "chat connection" to your overall connection to Hermes — it holds the app's connection open in the background so messages and live features stay responsive, and for relay-paired setups also keeps device control and notification mirroring reachable. The toggle moved out of Chat settings into the new top-level Quick Controls card.
|
||||
- **Chat is the home; simpler top-level navigation.** The Chat / Manage / Bridge mode strip is gone — Chat is now full-height, and Manage and Bridge are reached from Settings (Settings → Hermes management / Bridge), each with a back arrow to Chat. Terminal and Settings remain quick icons in the chat top bar.
|
||||
- **Gentler reconnects when your server is unreachable.** After the server has been unreachable for a while, the app stops retrying every ~15 seconds and drops to a slower poll — easier on the battery — and still reconnects immediately the moment the network changes or the server comes back.
|
||||
- **Connection status stays out of your way.** Connection feedback now sits exactly where it matters and never covers the nav or shifts the screen. Your **agent's** connection shows in the header subtitle under the agent name — it reads *Reconnecting…* / *Connecting…* / *Disconnected* and crossfades back to the model when it recovers, the same place messaging apps put it. The **relay** link (bridge / terminal / voice) shows only as a small amber *Reconnecting…* cue in the bottom status strip, since it doesn't block chat. Returning to the app from the background is now fully silent instead of flashing a misleading "connection changed" for the same connection re-handshaking.
|
||||
- **Realtime voice: quieter progress.** The periodic spoken status updates during a long task ("Using cronjob…") are now off by default — the agent speaks at the milestones that matter (task started in background, finished, or failed) and the visual progress chip covers the in-between. A server setting brings the timed narration back if you prefer it.
|
||||
- **Realtime voice: a live background-task chip.** The "working on it" chip in voice mode now actually shows what's happening: the current step ("Running command"), how many steps have finished, and a running timer — with a pulse so you can tell it's alive. It also reads the connection honestly ("Reconnecting — your task is still running" during a blip, "Done — delivering the answer…" while the reply queues up), and a ✕ on the chip cancels the task outright.
|
||||
- **Realtime voice: snappier long-task handoffs and first turns.** When a clearly long-running tool starts (cron, desktop, browser work), the agent hands the task to the background right away instead of waiting out the full grace period — and the voice session now warms up when you open voice mode, so the first turn skips the connection setup it used to pay.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Two voice controls that did nothing.** The disabled "Auto-TTS" toggle and the "STT language" picker under "Coming soon" in Voice settings are gone: the official desktop doesn't read every typed message aloud, and speech-to-text language is a server-side setting now editable in the new Server voice config section.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Realtime voice: you can keep talking while a background task runs.** Progress updates from a background task were flipping the voice UI back into "Thinking" with a Stop button on every tick, so the mic never came back until the task finished. Progress now feeds only the task chip; the conversation stays open the whole time.
|
||||
- **Realtime voice: leaving voice mode no longer cancels a running task.** Exiting (or tapping Stop to interrupt speech) used to kill an in-flight background task and could overwrite its already-delivered answer with "Cancelled." in the chat. Exit now detaches — the task keeps running and the result arrives on your next session or as a notification — and a delivered answer always keeps its text (a Stopped badge marks a genuine cancel). The chip's ✕ remains the one deliberate way to cancel.
|
||||
- **Long answers are no longer lost when the connection drops mid-turn.** On slow local models (or skills that delegate long background work), the phone could drop the stream mid-turn — the server finishes and saves the answer, but the chat sat on "Still working…" forever. The app now detects the dropped stream and quietly re-checks the conversation until the finished answer arrives, then completes the turn normally (with the usual done-notification if you've backgrounded the app). Switching chats or sending something new cancels the wait. (#166)
|
||||
- **Onboarding slides fit every screen.** Intro slide text could run past the bottom of the screen with no way to scroll on short displays or large font sizes. Slides now scroll when needed and compact their artwork on short viewports, so no setup guidance is unreachable. (#145)
|
||||
- **Docs: fixed stale setup labels and broken links.** The setup guide referenced a "Vanilla Hermes" button the app hasn't shown since v1.2.2 (it's labeled "Hermes"), several deep links into the getting-started page were dead, and the README under-counted the available phone tools. (docs site)
|
||||
- **Back button on Manage and Bridge now works.** The back arrow on the Manage ("Hermes management") and Bridge screens did nothing — it tried to jump to Chat in a way that silently no-op'd. Back now reliably returns to the screen you opened it from.
|
||||
- **Dropped relay connections from a status-report race.** The phone's periodic device-status report could occasionally be sent to the relay *before* the connection had finished authenticating, which made the relay reject the whole connection and forced a reconnect. The app now holds every message until the connection is authenticated, so the handshake always completes first.
|
||||
- **Fewer needless connection re-checks when switching apps.** Returning to the app after a quick glance at another app no longer triggers a full connection re-probe (and the brief "checking…" flash) when the connection was already healthy — it only re-checks after a longer absence or if something actually looks off.
|
||||
- **No more scary "server isn't accepting connections" pop-up on first load.** A bare bottom message could flash on cold start while the app was still establishing its first connection (the background session-list load failing before the server was reachable). That state is now shown only by the themed connection banner at the top — the redundant pop-up is suppressed for cold-start/reconnect bootstrapping, while real failures while you're using the app still surface normally.
|
||||
- **Reconnect loop on remote (Tailscale) connections.** Connecting from off your home network could make chat loop — repeatedly reconnecting before it finally settled — because a brief route-probe miss flipped the active route back to the (unreachable) home address and rebuilt the chat connection against it. The app now keeps the last working route through a transient miss, tolerates a slow first handshake on remote links, and absorbs VPN-interface churn, so a remote connection settles quickly instead of thrashing.
|
||||
- **Realtime voice: background tasks survive a brief disconnect.** Asking the voice agent to run a longer task in the background no longer loses the result to a momentary network drop — the server keeps the run alive across the reconnect and delivers the answer once you're back, and a task that runs too long is now stopped cleanly instead of hanging silently.
|
||||
- **Realtime voice: the spoken answer is no longer dropped when a background task finishes.** When the agent completed a longer background task, a harmless internal provider notice was being treated as a fatal error and closed the voice session right as the reply was about to be spoken (surfacing an "xAI realtime error" toast with Retry). Those transient notices no longer end the turn, so the answer is actually spoken.
|
||||
- **Realtime voice: the answer waits for you instead of playing to a dead connection.** If a background task finishes while your phone is disconnected, the spoken summary is now held and delivered when the voice session reconnects — and the phone keeps retrying that reconnect for several minutes instead of giving up after one attempt. If the voice session is gone for good, the result arrives as a notification instead (the full answer is always in the chat).
|
||||
- **Realtime voice: asking for a second task while one is running no longer breaks the first.** The agent now tells you the earlier task is still in progress (wait, check status, or cancel) instead of silently losing its result.
|
||||
|
||||
## [1.2.6] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Session drawer refresh.** A refresh button in the session drawer re-pulls the chat list on demand, so a title the server generates a moment after a turn shows up without waiting for the next reload.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Calmer connection status.** Transient connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (the screen slides down) instead of a card floating over the chat. The floating alert is reserved for persistent errors. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of the bottom pop-up.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chats stuck showing "Untitled".** The session drawer no longer overwrites a chat's first-message preview with a blank title when the server hasn't auto-named it yet (and the SSE path never does), so chats stop reading "Untitled"; titles also reconcile once the turn settles. (#133)
|
||||
- **Rename on a non-default agent profile.** Renaming a chat while a non-default profile is active now persists to that profile's own store instead of the shared one — matching the earlier session-delete fix.
|
||||
|
||||
## [1.2.5] - 2026-06-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Demo mode.** A "Try the demo" option on the setup / Connect screen — and on the empty chat screen if you skip setup — opens an offline preview of the real Chat UI: a sample conversation with Markdown, a tool-progress card, and a rich card, with zero setup and zero network (works in airplane mode). A persistent "Demo mode — sample data, not connected" banner offers a one-tap Connect that opens the real setup wizard; other tabs show a friendly "connect your Hermes server" empty state. Lets a first-run user — or a Play reviewer with no server — see what the app does before connecting.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crash when a non-address is entered as a server URL.** Typing or pasting non-URL text (for example a label, or a line copied from the docs) into the API server or Dashboard URL field could force-close the app on the Manage / sign-in screen: the value was handed to the networking layer as a host, which rejected it with an uncaught error on the main thread. The setup fields now reject anything that isn't a valid host or `http(s)://` URL with an inline error, and the dashboard and voice request paths treat a malformed address as "unreachable" instead of ever crashing. (#131, #132)
|
||||
|
||||
## [1.2.4] - 2026-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting or timing out over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing. (#129)
|
||||
|
||||
## [1.2.3] - 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crash on connect over TLS / Tailscale.** Connecting to a server over an encrypted link (Tailscale Serve or public HTTPS) could hard-close the app with `NetworkOnMainThreadException`. Tearing down an HTTP client closed live SSL sockets on the main thread, and a TLS socket close performs a network write — which Android forbids on the main thread. Client shutdown now always closes sockets off the main thread, so connecting over a secured link no longer crashes. (#118, #124; likely the v1.1.0 / Tailscale crash in #70)
|
||||
|
||||
## [1.2.2] - 2026-06-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Diagnostics: status timeline.** Diagnostics now opens full-screen and leads with a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a clear pass / warning / fail state and, when something's wrong, the reason why; tap a failing check for full detail. The recent-activity log stays below it.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connections wording simplified.** The default connection is now just "Hermes" (previously "Vanilla" / "Standard Hermes"), and the optional power features are labelled "Relay" / "Relay plugin", across the connection setup, switcher, voice, and permissions screens.
|
||||
- **Clean chat mode shows more text.** The distraction-free chat view gives its text a noticeably taller, scrollable area instead of capping it near a third of the screen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deleting a session on a non-default profile now sticks.** Removing a chat while a non-default agent profile was active could leave it on the server, so it reappeared after the list refreshed; the delete is now scoped to the active profile.
|
||||
- **Session drawer opens on the right profile from a cold start.** When launching with a non-default profile selected, the session list could briefly show the default profile's chats and then snap to the correct ones; it now waits for the profile to resolve and loads the right list directly.
|
||||
|
||||
## [1.2.1] - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile lock.** Settings → Profile lock pins the app to a single agent profile and hides the rest from the pickers; the lock screen stays the one place that lists every profile, with a clear notice if the locked profile isn't on the current server.
|
||||
- **In-app What's New & changelog.** A new Settings entry shows the current and past release notes any time — not just the post-update popup.
|
||||
- **Diagnostics: tap for detail + report.** Logged errors now carry clean titles and open a detail view with Copy / Share / Create-GitHub-issue (the same flow as crash reports); classified errors across voice, chat, and connection are captured centrally.
|
||||
- **Update-available nudge.** A dismissable in-app banner when a newer version is live — Google Play In-App Update on Play installs, GitHub Releases on sideload. Per-version dismissal, throttled, never nags.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice override applies in Auto mode.** A chosen per-profile/enhanced voice now takes effect when the engine is on Auto with the relay paired — previously only "Relay" mode applied it. Per-profile voice settings are also namespaced by connection.
|
||||
- **Realtime voice "Stop" stops immediately.** Tapping Stop while the agent is speaking now halts realtime playback at once; over-chatty spoken status is throttled; and long background tasks no longer time out the turn (relay keeps the session alive while the task runs).
|
||||
- **Realtime Agent: brokered Hermes turns no longer fail (relay).** When the Realtime Agent reached back to Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn with `session_not_found`. The relay now mints or reuses a valid API Server session and retries once, and reads the API Server's current nested create-session response. Provider-native turns are unaffected.
|
||||
- **Hold-to-talk no longer releases on accidental drift.** The mic button holds until the finger genuinely lifts, instead of cancelling when it drifts off the button.
|
||||
- **Voice overlay is readable.** The voice dropdown panel and its status bubbles are opaque (no bleed-through), and the Focus/Overlay/Exit labels no longer wrap to two lines; invalid engine/route combinations are no longer selectable.
|
||||
- **Connection status overlay clears faster.** Resolved (error/warning) connection toasts auto-dismiss within ~5s instead of lingering.
|
||||
|
||||
## [1.2.0] - 2026-06-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Sensitive-media classification (relay).** The relay teaches the agent — server-side, via a removable system-prompt block — to mark private/NSFW media so the phone blurs it per your setting. **On by default for relay installs** (installing the relay is itself the opt-in); reversible from the "Agent context" toggle in the Relay dashboard, or `RELAY_AGENT_CONTEXT_ENABLED=0`. The exact injected instruction is visible in the chat "What the agent sees" sheet under "Relay context (server-side)". No on-device or relay-side classifier — sensitivity stays model-emitted. Vanilla upstream (no plugin) is unaffected. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
|
||||
- **Transport path is visible (chat).** The chat status strip now shows which streaming path is actually in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — instead of a generic "api online", and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
|
||||
- **Injected-context audit (chat).** Tap the context-usage meter in chat to open a "What the agent sees" sheet showing the exact extra context prepended to your next turn — persona/profile, phone status, and any per-turn (voice) hint. On the gateway path it notes the persona is applied server-side, so the audit is honest about what the phone does and doesn't send.
|
||||
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
|
||||
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
|
||||
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
|
||||
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
|
||||
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
|
||||
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
|
||||
- **Agent pets — a living, swappable avatar.** The orb can be replaced with an animated "pet" that reacts to what the agent is doing: idle / thinking / writing / speaking / listening states, a distinct **working** pose during tool calls, one-shot **greet** / **celebrate** reactions, and a loop that quickens as output streams. Add or remove pets right in Settings → Appearance (no `adb` needed), with a live state preview, a playback-speed slider, and optional frame auto-stabilization; capability badges (Voice · Tools · Activity) show honestly what each pet actually reacts to. Pets are pure data — an AI authoring kit and a JSON schema let you generate one from sprite art. See `docs/pet-spec.md` and the custom-avatars guide.
|
||||
- **Per-profile agent icon + single-image avatars.** Each agent profile can wear its own small icon beside its name (client-side, never sent to Hermes), shown in chat, the agent sheet, the top bar, and Settings. Importing an avatar now also accepts a single image (auto-wrapped as a one-frame pet) — no animated pack required.
|
||||
- **In-app crash reporting.** If the app ever force-closes, the next launch shows a clean dialog with the stack trace — **Copy** it, or **Report** to open a pre-filled GitHub issue from the bug template. The report persists until you acknowledge it, and the handler re-raises so the OS still records the crash in Play vitals.
|
||||
- **Clean text-flow mode (chat).** A distraction-free chat layout where your sent text slides up into a continuous flow, paired with the swappable-avatar/pet system.
|
||||
- **Permissions review screen.** A central page makes the permission model explicit — standard Chat and Manage need no phone-control permissions, while voice, camera, notifications, and sideload Device Control stay opt-in — reading the same live grants Bridge does.
|
||||
- **In-app attachment previews + richer capture.** Attachments preview inline before sending, sensitive media is blurred per your setting, and the capture flow is richer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Much faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, which serialize on a process-global lock and stalled the chat header (model, personality, approvals) for seconds. It now builds a single keyset and the dashboard cookies share it, cutting measured time-to-connected from ~2.9 s to ~1 s after first frame, with the keystore lock contention gone. Existing sign-ins are migrated automatically on first launch.
|
||||
- **Honest loading, never stale, never hidden.** Model, personality, and approvals now show a brief "checking…" state and fade in once the server confirms them, instead of popping in or showing a possibly-wrong value. Standard upstream controls (Model, YOLO, Fast, reasoning effort) are no longer hidden while loading or when unavailable — they always appear: a live control when ready, "checking…" while a value loads, or a cleanly disabled control with the reason (e.g. "available over the gateway transport") when this connection can't use them. The chat composer's reasoning-effort chip now shows alongside the model chip instead of lagging seconds behind the gateway check, and picker lists (models, personalities) show a brief, bounded "loading…" cue. The same fade-in is applied to the context meter, session drawer, and Manage panels.
|
||||
- **Tidier chat header.** The LAN/Tailscale chip was dropped from the top bar (the bottom status strip already shows the route, and is now tappable to open Connections), and a `none` personality is no longer shown — leaving more room for the model name.
|
||||
- **Connection toast reads like the cold-start screen.** The floating connection status toast now shows a live checklist — Route / API / Relay each with a spinner, ✓, or ✕ as the checks land — instead of flat text, matching the splash screen's stepper. Swiping it up now tracks your finger (slide + fade) rather than snapping, and connection problems get an explicit "Open Connections →" link at the bottom so the path to the detailed view is obvious.
|
||||
- **Tidier chat header.** The "approvals off" warning moved out of the agent subtitle into a single amber ⚡ icon in the top bar (tap for the full explanation in the agent sheet), and Share folded into a ⋮ overflow menu — so the personality · model subtitle no longer gets clipped by the trailing action icons.
|
||||
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
|
||||
- **Leaner terminal screen (Android).** The extra-keys bar scrolls horizontally with compact, fully-legible keys (no more clipped "CTRL"), the header is a single compact row showing one inline connection-status dot plus state, and the tab strip is hidden for single-tab sessions — the new-tab "+" moves into the header — reclaiming vertical space for the terminal.
|
||||
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions now use a dedicated tmux server/socket with its own config — instant ESC (`escape-time 0`), truecolor `$TERM`, mouse and focus events on, and no status bar — so editors and full-screen tools behave correctly, without touching the user's personal tmux.
|
||||
- **"Standard" is now "Vanilla Hermes" throughout.** The user-facing name for the no-plugin upstream path is now **Vanilla Hermes**, so it's clear the default path runs on a plain Hermes agent.
|
||||
- **QR pairing degrades gracefully on unusual cameras.** On foldables and devices where the camera can't initialize, the scanner now shows a "camera unavailable — pair manually" card instead of force-closing.
|
||||
- **Image & attachment viewers rotate to landscape.** The full-screen image / attachment viewers can rotate to landscape even though the rest of the app stays portrait-locked.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Clearer error when a feature needs a newer relay.** Toggling a setting an older relay plugin doesn't recognize (e.g. xAI expressive speech tags) now shows "Relay update needed" instead of a generic HTTP 400 with a dead Retry button. Genuine input errors are unaffected.
|
||||
- **Connection status toast is no longer see-through.** The floating connection-lost/switching toast renders fully opaque so content behind it no longer bleeds through and hurts legibility.
|
||||
- **Provenance badges survive the post-turn history reload.** "Voice", "Realtime Agent", "Stopped", and "Error" chips are now preserved when the conversation reloads after a turn, instead of silently vanishing.
|
||||
- **Chat and Manage no longer stay dark in Light mode.** Brand-styled surfaces bypassed the theme and were effectively hardcoded dark; they now follow the selected theme and light/dark mode, and the glow/border flourishes key off the active theme rather than the system setting.
|
||||
- **Realtime voice no longer drops the conversation mid-session with some providers.** A normal end-of-turn signal was being rejected on certain voice providers, ending the session every turn.
|
||||
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
|
||||
- **Clearer voice errors and an oversize-recording guard.** Standard voice now rejects an over-long recording before uploading it and shows a helpful message for audio the server can't read, instead of a generic HTTP error.
|
||||
- **Terminal paste no longer auto-runs multi-line text.** The key-bar PASTE now uses bracketed paste, so multi-line content lands intact in shells and editors instead of executing line by line.
|
||||
- **Terminal on-screen arrows behave inside TUIs.** Arrow/Home/End keys follow the running app's cursor-key mode (application vs. normal), so they work correctly in vim, less, and fzf.
|
||||
- **Terminal footer spacing.** A small gap now keeps the last terminal row clear of the key bar (it could previously look like the footer overlapped it), and a redundant navigation-bar inset that left empty space below the keys was removed.
|
||||
- **In-chat model picker now actually applies on a new chat.** Picking a model and provider in the chat composer (e.g. Grok 4.3 via your xAI subscription) is bound to the new conversation, so the agent runs on the picked model instead of silently falling back to the account's global default. Switching profiles retires an explicit pick so the profile's own model takes over, and the picker label updates immediately instead of lagging a round-trip.
|
||||
- **Server-generated images render in chat when paired to the relay.** An assistant image that points at a server-side file path is now fetched through the relay's media route and shown inline (tap to zoom), instead of degrading to an "image is on the server" notice. On the SSE chat path the agent is also told it can surface images and files by path when a relay route is configured (visible in the chat "What the agent sees" sheet). Standard (no-plugin) connections are unchanged.
|
||||
- **Smoother profile switching.** Switching profiles no longer blanks the conversation to an empty/"Loading…" state before the new history loads; the previous transcript is held and cross-fades to the new one.
|
||||
- **In-chat model switch now applies mid-conversation, not just on new chats.** Picking a model in an already-started chat switches the live session in place — the same path the desktop/TUI `/model` uses — instead of racing into a global-default write, so the turn runs the model you picked.
|
||||
- **Server-side turn errors always surface.** A failed turn (e.g. a provider rejecting the request) now stays on screen as an error bubble with the message, instead of appearing for a moment and then vanishing when the conversation reconciled after the turn.
|
||||
- **The model shown in chat matches the live session.** The chat header and the agent detail sheet now show the model the current session is actually running (reflecting a mid-session switch) rather than the profile/global default, and the agent sheet no longer pairs the global default model name with the session's provider — it now also names the host's "Server default" when the session runs something different.
|
||||
- **Server steering markers no longer appear as chat bubbles.** The "[System: the active model/personality changed]" notes the server injects into history for the agent's benefit are hidden from the transcript by default (matching the desktop/TUI); a new "Show system messages" debug toggle in Chat Settings can reveal them.
|
||||
- **Per-reply token counts (and other per-message details) survive the post-turn reload.** The input/output token subtext, provenance badges, tapped-card state, and voice/realtime sync traces are now preserved when the conversation reconciles against the server after a turn — previously a normal reply lost its token line once the turn finished (the error bubble kept it only because errored turns skip that reload). The reloader now preserves client-only message details by default instead of dropping any it doesn't re-derive from the server.
|
||||
- **PDF viewer no longer crashes when the document closes mid-render.** A PDF preview that was torn down during a layout pass could read a closed renderer and throw `IllegalStateException: Document already closed`; the renderer is now guarded so it returns nothing instead of crashing.
|
||||
- **No crash opening a chat with a server-local image.** Rendering a relay-fetched image could throw `ClassCastException: kotlin.Result cannot be cast to byte[]` because a `suspend` function returned `kotlin.Result` (which collides with the coroutine machinery's own wrapper); a purpose-built result type fixes it.
|
||||
- **Side-loaded avatars and sphere skins are reachable again.** Both loaders read internal storage while the docs (correctly) pointed `adb push` at external app-scoped storage, so a side-loaded pet or skin never appeared. Both now resolve through one external-preferred location, so the documented install path works.
|
||||
- **Reopened chats paint the session's real model** (not the profile/global default), the model-picker "Server default" caption shows the true default rather than the active override, and a chat's media badge shows only when paired — with the underlying server-image fetch-failure reason surfaced when a fetch fails.
|
||||
|
||||
## [1.1.0] - 2026-06-16
|
||||
|
||||
@@ -266,11 +667,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://192.168.1.100:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
|
||||
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://192.168.1.100:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1274,7 +1675,12 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.4...HEAD
|
||||
[1.4.4]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.3...android-v1.4.4
|
||||
[1.4.3]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.2...android-v1.4.3
|
||||
[1.4.2]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.1...android-v1.4.2
|
||||
[1.4.1]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...android-v1.4.1
|
||||
[1.4.0]: https://github.com/Codename-11/hermes-relay/compare/android-v1.3.0...android-v1.4.0
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
|
||||
@@ -1,84 +1,93 @@
|
||||
# Hermes-Relay — Claude Code Context
|
||||
# Hermes-Relay — Claude Code Adapter
|
||||
|
||||
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
|
||||
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
|
||||
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
|
||||
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
|
||||
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Standard chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. The Relay plugin adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management via the official Hermes web dashboard.
|
||||
|
||||
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Standard voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
**Current state:** Reference latest released version for stable state and current dev branch for working state. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (WS) -> Hermes dashboard (:9119) [standard gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [standard chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [standard Manage + voice]
|
||||
Phone (WS) -> Hermes dashboard (:9119) [vanilla Hermes gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [vanilla Hermes chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [vanilla Hermes Manage + voice]
|
||||
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
|
||||
```
|
||||
|
||||
The standard path must stay vanilla upstream only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; standard chat, Manage, and dashboard voice must not.
|
||||
The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; Vanilla Hermes chat, Manage, and dashboard voice must not.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
|
||||
|
||||
**Standard endpoints (confirmed in hermes-agent source):**
|
||||
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
|
||||
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
| --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (``💻 terminal``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
|----------|---------|-----------------|
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file, injecting only compatibility-only surfaces (session search, memory, legacy skill detail/toggle, config, available-models, slash middleware). Sessions CRUD/messages/fork and the legacy skills list are **retired** — native upstream owns them (#33134/#33016) and the bootstrap carries no fallback for old builds. Native routes still win per method/path for the remaining set. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
| -------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap injection retired |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap injection retired |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills/{name}` | Legacy skill detail | Bootstrap compat; list (`GET /api/skills`) retired — use native `/v1/skills` |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and `**POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **Vanilla Hermes (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, standard voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the standard fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, Vanilla Hermes voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the SSE fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (``💻 terminal``).
|
||||
|
||||
## Key Instructions
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — gateway/API chat, Manage, and standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Done: sessions CRUD/messages/fork and the legacy skills list are retired from the bootstrap (native upstream #33134/#33016, no old-build fallback kept). Remaining: config, memory, legacy skill detail/toggle, available-models, session search, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -115,6 +124,7 @@ hermes-android/
|
||||
│ │ ├── transport/ # RelayTransport (reconnect state machine + TLS probe TOFU)
|
||||
│ │ └── lib/ # gracefulExit, rpc, circularBuffer (vendored)
|
||||
│ └── scripts/ # install.sh + install.ps1 curl/iwr one-liners
|
||||
├── website/ ← Astro product/marketing site (static Coolify/Nixpacks deployment)
|
||||
├── plugin/ ← Hermes agent plugin
|
||||
│ ├── android_tool.py # 18 android_* tool handlers
|
||||
│ ├── pair.py # QR pairing implementation
|
||||
@@ -131,9 +141,11 @@ hermes-android/
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, .gitignore
|
||||
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, TODO.md, .gitignore
|
||||
- **docs/** — spec, decisions, security, and any other long-form documentation
|
||||
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
|
||||
- **DEVLOG.md** — update at end of each work session with what was done + verification (the factual record of *what happened*). It churns; do NOT park forward work here.
|
||||
- **TODO.md** — the single home for follow-ups / deferred work / known gaps ("what's next"). Record them here — never buried in DEVLOG or scattered through code/doc comments where they get lost.
|
||||
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
|
||||
|
||||
### Public-repo writing hygiene
|
||||
@@ -148,15 +160,17 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
- **OkHttp** for WebSocket + SSE — `okhttp` for WSS relay, `okhttp-sse` for API streaming
|
||||
- **Single-activity** — Compose Navigation for all routing
|
||||
- **Namespace (Kotlin source tree):** `com.hermesandroid.relay` — stable, drives on-disk layout + class FQCNs
|
||||
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 37** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
@@ -164,169 +178,183 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
- **asyncio** — no threading; **structured logging** — use `logging`, not print()
|
||||
|
||||
### Git
|
||||
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
|
||||
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
|
||||
a Claude-specific parallel policy here.
|
||||
|
||||
### Testing
|
||||
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
- **CI and release gates:** follow the repository-wide requirements in
|
||||
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
|
||||
| File | Why |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold (Scaffold + Compose nav); Chat is home — no mode strip, Manage/Bridge reached via Settings; `bottomBar` is a status pill, not a NavigationBar |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch — compat-only surfaces (session search, memory, skill detail/toggle, config, available-models, slash middleware); sessions + skills-list injection retired (#33134/#33016) |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
|
||||
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
|
||||
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
|
||||
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
|
||||
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
|
||||
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
|
||||
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
|
||||
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
|
||||
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
|
||||
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
|
||||
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context/queue` — relay-server management surface; info/security/queue loopback-only, context works remote with bearer; `queue` lists/cancels the agent→phone outbound buffer (`--clear` / `--cancel <id>`) |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
|
||||
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -335,16 +363,20 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Don't use Ktor for networking** — OkHttp for WebSocket
|
||||
- **Don't use plaintext WebSocket** — `wss://` only, even in development
|
||||
- **Don't put documentation in root** — long-form docs go in `docs/`
|
||||
- **Don't forget DEVLOG.md** — update it
|
||||
- **Don't forget DEVLOG.md** — update it (record *what happened*)
|
||||
- **Don't bury follow-ups** — deferred work / known gaps go in `TODO.md`, never in DEVLOG or one-off code/doc comments
|
||||
- **Don't touch production / remote hosts** — automation and orchestrated agents must NEVER SSH into, deploy to, pull/restart/reconfigure, or push code to a live/remote Hermes host. Building, on-device testing, and server deployment are owner-driven (see Server Deployment). Stop at committing on your branch; surface "this needs a deploy/on-device check" rather than doing it.
|
||||
|
||||
## MCP Tooling
|
||||
|
||||
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
|
||||
|
||||
| Server | Layer | Requires |
|
||||
|--------|-------|----------|
|
||||
|
||||
| Server | Layer | Requires |
|
||||
| ------------------- | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
@@ -374,8 +406,8 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
|
||||
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
|
||||
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
|
||||
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
|
||||
6. **Pull + restart on server** — see Server Deployment below.
|
||||
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
|
||||
@@ -383,96 +415,105 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
|
||||
Server is a Linux box running hermes-agent with hermes-relay editable-installed (`pip install -e`). Sensitive details (IP, user, secrets) in `~/SYSTEM.md` on the server — not in this repo.
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
| What | Where |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
package is only a legacy import shim. Standard chat, Manage, and dashboard voice
|
||||
|
||||
package is only a legacy import shim. Vanilla Hermes chat, Manage, and dashboard voice
|
||||
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
|
||||
- Phone pairing **survives** relay restart — `SessionManager` persists sessions to `~/.hermes/hermes-relay-sessions.json` (`server.py:88-90`, `persistence_path` from `RelayConfig.from_env`); a trusted-device refresh token recovers a lost/revoked/reset session without a new QR scan. (Only the in-memory *live-connection presence* clears on restart; the phone reconnects automatically.)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
- `_env_bootstrap.py` loads `~/.hermes/.env` on every relay start — no stale API keys
|
||||
|
||||
### Where Python vs. Kotlin changes land
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
|---|---|---|
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
| ------------------------------------ | ------------------------ | -------------------------------------------------- |
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
|
||||
### Release Process
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
|
||||
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
|
||||
hotfixes, secrets, publishing, and verification. Claude-specific automation
|
||||
must not infer release authority from feature completion.
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Standard upstream dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Standard upstream dashboard surface; do not proxy through Relay |
|
||||
| Standard voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Standard no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable\|disable\|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Vanilla Hermes dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback retired |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
|
||||
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable|disable|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
|
||||
## Upstream References
|
||||
|
||||
| Topic | Upstream File |
|
||||
|-------|--------------|
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
| Topic | Upstream File |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — the agent platform (gateway, WebAPI, plugin system)
|
||||
- **[android-tools-mcp](https://github.com/Codename-11/android-tools-mcp)** — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
- [**hermes-agent**](https://github.com/NousResearch/hermes-agent) — the agent platform (gateway, WebAPI, plugin system)
|
||||
- [**android-tools-mcp**](https://github.com/Codename-11/android-tools-mcp) — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- [**mobile-mcp**](https://github.com/mobile-next/mobile-mcp) — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
|
||||
|
||||
@@ -1,64 +1,63 @@
|
||||
# Hermes-Relay-CLI v__VERSION__
|
||||
|
||||
**Release Date:** <!-- YYYY-MM-DD -->
|
||||
**Since the previous CLI release:** <!-- one line: the theme of this release -->
|
||||
**Release Date:** 2026-07-13
|
||||
|
||||
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
|
||||
This alpha makes the desktop direction explicit: Hermes-Relay is a real CLI/TUI with an optional Windows right-click systray—not a second desktop application. The old Tauri/WebView dashboard and its embedded windows are gone. The installed CLI remains the single source of behavior for pairing, TUI, daemon management, grants, audit, diagnostics, chat, voice, and tools.
|
||||
|
||||
<!--
|
||||
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
|
||||
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
|
||||
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
|
||||
leave those tokens in the Install section; do NOT hardcode versions there.
|
||||
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
|
||||
bullets in CHANGELOG.md's promoted version block.
|
||||
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
|
||||
• Keep the "Experimental phase" notice until the CLI reaches GA.
|
||||
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
|
||||
no fork-branch plumbing, no AI self-narration.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
**Experimental phase.** Assets are unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the optional native systray is Windows-only.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
-
|
||||
|
||||
- **Persistent desktop-use control.** `hermes-relay computer-use status|enable|disable|cancel` stores one local preference, reports daemon privilege and active/pending grants, and can end an active task-scoped grant without relying on a GUI.
|
||||
- **Headless grant review.** `hermes-relay grants` lists pending local computer-use requests and supports interactive review plus explicit `approve`, `reject`, and JSON forms for scripts.
|
||||
- **Typed Relay chat option.** `chat --relay-chat` sends `chat.send` over WSS and renders typed `stream.event` v1 assistant, tool, artifact, memory, skill, and error lifecycles while preserving the existing gateway path as the default.
|
||||
- **Release-parity verification.** One version contract now keeps the npm package, compiled CLI, Rust tray, lockfile, and installer metadata aligned. The Windows verification target covers TypeScript, compiled-binary smoke tests, Rust formatting/lint/check/tests, and installer packaging.
|
||||
|
||||
### Changed
|
||||
-
|
||||
|
||||
- **Menu-only Windows systray.** The optional tray is a small native Rust process with no application window, WebView, overlay, embedded terminal, chat view, voice view, or settings dashboard. Interactive actions open the installed CLI in a normal terminal.
|
||||
- **State- and privilege-aware daemon control.** The menu reports PID-backed daemon state and User/Administrator privilege, disables invalid lifecycle actions, and requests UAC only when **Start/Restart daemon as Administrator…** is explicitly chosen. The tray itself remains unprivileged.
|
||||
- **Visible desktop-use safety.** The tray shows enablement, active grant mode and expiry, warns when an Administrator control grant is active, raises a native alert for pending approvals, opens CLI grant review, and provides immediate cancellation and emergency stop.
|
||||
- **Per-user Windows installation.** The default PowerShell installer downloads the checksum-verified NSIS package, installs the CLI and optional tray under `~/.hermes/bin`, adds Start-menu shortcuts and user PATH, and can start the tray at sign-in. CLI-only installation remains available with `HERMES_RELAY_INSTALL_SURFACE=cli`.
|
||||
|
||||
### Fixed
|
||||
-
|
||||
|
||||
- **Installed-binary diagnostics.** `hermes-relay doctor` reports the physical Bun-compiled executable instead of a virtual embedded-module path, so PATH and install-directory checks describe the binary that actually launched.
|
||||
- **Release guardrails.** CLI tag automation rejects version drift, tags not contained in `main`, oversized tray binaries, or a tray process that creates an application window.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
**Windows CLI + optional systray (PowerShell):**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=__TAG__`.
|
||||
Pin this release with `HERMES_RELAY_VERSION=__TAG__`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
hermes-relay pair --remote ws://<host>:8767 --grant-tools
|
||||
hermes-relay daemon start
|
||||
hermes-relay daemon status
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Code of Conduct
|
||||
|
||||
Hermes-Relay adopts the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),
|
||||
version 2.1, as its code of conduct. The canonical, full text lives at that
|
||||
link; the summary below states what it means for this project.
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Behavior that helps create a positive environment includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Being respectful of differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility, apologizing to those affected by our mistakes, and
|
||||
learning from the experience.
|
||||
- Focusing on what is best for the overall community, not just ourselves.
|
||||
|
||||
Behavior that is not acceptable includes:
|
||||
|
||||
- Harassment, intimidation, or discrimination in any form.
|
||||
- Personal or political attacks, insults, or derogatory comments.
|
||||
- Unwelcome advances or attention, including of a romantic or sexual nature.
|
||||
- Publishing others' private information (such as a physical or email address)
|
||||
without their explicit permission.
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
For the complete, canonical list of standards and examples, see the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing these standards
|
||||
and will take appropriate and fair corrective action in response to any behavior
|
||||
they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, issues, and other contributions that are not aligned
|
||||
with this Code of Conduct, and will communicate reasons for moderation decisions
|
||||
when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces — the repository, issues,
|
||||
pull requests, discussions, and the documentation site — and also applies when
|
||||
an individual is officially representing the project in public spaces.
|
||||
|
||||
## Reporting & Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported privately to the maintainers at **`conduct@codename-11.dev`**. All
|
||||
complaints will be reviewed and investigated promptly and fairly. Maintainers
|
||||
are obligated to respect the privacy and security of the reporter of any
|
||||
incident.
|
||||
|
||||
For the **Enforcement Guidelines** (the tiered Correction → Warning →
|
||||
Temporary Ban → Permanent Ban ladder maintainers use to determine consequences),
|
||||
see the corresponding section of the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines).
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
@@ -92,9 +92,69 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
|
||||
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
**Branching model: `main` + `dev`.** Feature branches — `feature/<name>`,
|
||||
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
|
||||
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
|
||||
`main` is release history, not the normal contribution target; it receives
|
||||
approved release PRs from `dev` and focused hotfix PRs based on production tags.
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
Feature completion means merged and verified on `dev`; it does not mean the
|
||||
change has been released. A separate Forge release issue/session owns release
|
||||
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
|
||||
deployment, and live verification. Release-prep commits land on `dev`; tags are
|
||||
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
|
||||
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
|
||||
procedures.
|
||||
|
||||
## Stale PR salvage and contributor credit
|
||||
|
||||
A valuable pull request can become unsafe to merge when `dev` has materially
|
||||
changed around it. Maintainers may create a replacement **salvage PR** from the
|
||||
current `dev` instead of resolving a stale branch by choosing whole conflict
|
||||
sides.
|
||||
|
||||
A salvage PR must:
|
||||
|
||||
- Link the original PR and contributor in its title or opening summary.
|
||||
- Recover only the intended feature; unrelated fork, release, signing, and
|
||||
generated migration changes stay out.
|
||||
- Preserve the original commit author when a substantive commit can be safely
|
||||
cherry-picked.
|
||||
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
|
||||
must be reconstructed or substantially rewritten.
|
||||
- Include a `Lineage` section listing source and superseded PRs, plus a concise
|
||||
explanation of integration changes made for current `dev`.
|
||||
- Run current verification rather than relying on checks from the stale branch.
|
||||
- Leave a comment linking the replacement before the source PR is closed.
|
||||
|
||||
The maintainer remains the committer for integration commits. The original
|
||||
contributor remains the author or co-author of the recovered work. Do not guess
|
||||
an email address: use the source commit's verified address or ask the
|
||||
contributor.
|
||||
|
||||
## Localization contributions
|
||||
|
||||
English resources are canonical and Android locale catalogs must retain exact
|
||||
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
|
||||
before changing user-facing strings or adding a language.
|
||||
|
||||
Translation PRs should cover one locale or one clear catalog refresh. They must
|
||||
not include custom APK publishing, signing configuration, version bumps, or
|
||||
fork-specific branding. Run:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
Update `docs/localization-status.json` with the actual review level. AI-assisted
|
||||
translations may ship as `ai-translated`; do not claim fluent review unless a
|
||||
review reference is recorded. Focused correction PRs from fluent contributors
|
||||
are the canonical way to improve wording and can advance a locale to
|
||||
`community-reviewed` or `verified` under `docs/translation-playbook.md`.
|
||||
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
|
||||
the canonical project description. User docs may be added incrementally under
|
||||
`user-docs/<locale>/`, with links back to canonical English reference material.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
@@ -108,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# GEMINI.md
|
||||
|
||||
Agent instructions for **Hermes-Relay**. This file exists so Gemini CLI (which
|
||||
does not read `AGENTS.md` natively) picks up the project's guidance.
|
||||
|
||||
**Read [AGENTS.md](AGENTS.md) — it is the single source of truth** for every
|
||||
coding agent: the entry point, the non-negotiables (standard-path-is-vanilla-
|
||||
upstream, verify-endpoints, Conventional Commits + `main`/`dev` branching, the
|
||||
per-language stack rules), and the public-repo writing hygiene. It links on to
|
||||
`CLAUDE.md` for the deep reference (architecture, upstream Hermes API, repo
|
||||
layout, code style, the dev loop, and the Key Files map).
|
||||
|
||||
Do not restate rules here — keep them in `AGENTS.md` so they can't drift.
|
||||
@@ -1,37 +1,38 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 16, 2026
|
||||
**Since the previous plugin release:** Easier setup and a fixed dashboard panel — plus mid-conversation `/relay` controls and a relay-status widget.
|
||||
**Release Date:** July 22, 2026
|
||||
|
||||
This release makes the relay plugin easier to install and live with. Setup now prompts for the optional voice-provider keys instead of asking you to hand-edit `.env`, tools-only hosts can install through the native `hermes plugins install` path, and the installer no longer breaks on `uv`-managed Hermes cores. The dashboard panel — which previously rendered as blank boxes on the host's design system — now displays correctly, and a header widget plus `/relay` slash commands surface relay state from anywhere. The standard no-plugin path needs none of this.
|
||||
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
|
||||
|
||||
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
- **Guided env-key setup.** The plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of requiring a hand-edited `.env`. The standard no-plugin path needs none.
|
||||
- **Native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` are usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
|
||||
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
|
||||
|
||||
### Fixed
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Dashboard buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button` / `Badge` use boolean variant flags (`outlined` / `ghost` / `invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter and drops a label-hiding CSS reset.
|
||||
- **Unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background; solid button variants now keep their proper contrast colour.
|
||||
|
||||
## Install
|
||||
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
|
||||
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
|
||||
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
```
|
||||
## Install / update
|
||||
|
||||
# Native upstream plugin path:
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
|
||||
# Classic install / update on a systemd host:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
hermes relay doctor
|
||||
python scripts/check-plugin-version-sync.py --expect __VERSION__
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
|
||||
relay/plugin releases used `relay-v*` tags.
|
||||
Tag prefixes: Android releases use android-v*, Server releases use server-v*, and Desktop releases use desktop-v*.
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
@@ -38,6 +39,10 @@ Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-age
|
||||
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
|
||||
</p>
|
||||
|
||||
## Quick Start (Android)
|
||||
|
||||
Install → connect → talk, in about two minutes.
|
||||
@@ -45,56 +50,52 @@ Install → connect → talk, in about two minutes.
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2 · Have Hermes running
|
||||
### 2 · Have the Hermes Dashboard running
|
||||
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is standard Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
|
||||
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
|
||||
provider is vanilla Hermes setup:
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
hermes dashboard # start the standard Dashboard/Gateway surface
|
||||
```
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
|
||||
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
|
||||
covers Windows, remote access, and dashboard authentication. You do not need to
|
||||
enable the separate API server or invent an API key for the standard path.
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
Open the app, choose **Connect to Hermes**, and enter or discover the dashboard
|
||||
address (conventionally `http://<host>:9119`). Sign in through the dashboard's
|
||||
configured provider when prompted. The app probes the available upstream
|
||||
capabilities and finishes with a connection summary.
|
||||
|
||||
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
The separate API server can be discovered automatically or added later under
|
||||
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||
key is requested only when that optional endpoint is configured. Existing
|
||||
API-first setup QRs remain importable.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|------|---------------|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Chat** | Dashboard/Gateway ready — you can talk |
|
||||
| **Manage** | Models, keys, skills, and profiles are available from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
| **API fallback** | Optional API route available/unavailable |
|
||||
| **Relay** | Optional extensions — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole standard setup.
|
||||
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
|
||||
the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
@@ -128,7 +129,7 @@ the QR from the phone's Connections screen — or use
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ on the server. The API server and Relay are optional.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -140,20 +141,35 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_commands.png" alt="Command palette" width="100%"><br><sub><b>Command palette</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_themes.png" alt="App themes" width="100%"><br><sub><b>App themes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_appearance.png" alt="Agent avatar & skins" width="100%"><br><sub><b>Avatars & skins</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
### Simplified Chinese
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置 — 全面汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理 — 仪表盘汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航菜单 — 简体中文</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
|
||||
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
|
||||
**Settings → Appearance → Language**; translation status and fluent review are
|
||||
tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — rides standard Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
|
||||
- **Streaming chat** — rides vanilla Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
|
||||
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
@@ -163,11 +179,11 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
> **Alpha.** Self-contained CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64 — no Node required. Windows also has an optional native, menu-only systray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
|
||||
@@ -177,26 +193,28 @@ irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scri
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay daemon start # background tool router — agent reaches you anytime
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
|
||||
|
||||
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, standard voice]
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, vanilla voice]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
|
||||
back to the upstream API server SSE path with the API key. Manage and standard
|
||||
back to the upstream API server SSE path with the API key. Manage and Vanilla Hermes
|
||||
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
|
||||
install needs no plugin for either. The optional relay on `:8767` adds the power
|
||||
surfaces: terminal, bridge phone control, media handoff, machine tools, and
|
||||
@@ -207,14 +225,14 @@ configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://hermes-relay.dev/docs/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
@@ -232,7 +250,7 @@ Read the canonical setup recipe before acting:
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Connecting my phone by Vanilla Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
@@ -307,7 +325,7 @@ docker build -t hermes-relay relay_server/ && docker run -d --network host --nam
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
Then restart hermes and run `hermes pair` to verify. The 35 `android_*` and 25 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -323,9 +341,9 @@ This is an indie project and every report helps shape where it goes next. If som
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Codename-11%2Fhermes-relay&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&theme=dark&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<p align="center">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — 随身携带您的 Hermes 代理" width="800">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>运行在您的电脑上,连接到您的设备。</strong><br>
|
||||
Hermes-Relay 是 <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的原生 Android 客户端,提供流式聊天、免手动语音和代理管理;另有单文件 CLI,让代理在已配对的电脑上安全使用终端、文件和截图工具。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
|
||||
> 英文 [README.md](README.md) 是最新、完整的项目说明。本页维护中文安装入口和核心功能摘要;协议、架构和维护者文档以英文版本为准。
|
||||
|
||||
## 功能简介
|
||||
|
||||
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、多连接和配置文件。
|
||||
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
|
||||
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音和电脑工具。
|
||||
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
|
||||
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
|
||||
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 Android 应用
|
||||
|
||||
- [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay):自动更新,包含聊天、语音、管理、终端、媒体和通知功能。
|
||||
- [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases):下载最新 `android-v*` 版本中以 `-sideload-release.apk` 结尾的文件,获得完整手机控制功能。
|
||||
|
||||
### 2. 启动 Hermes API 服务
|
||||
|
||||
手机需要能够访问 Hermes API 服务,并使用 API 密钥进行身份验证:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<电脑IP>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`0.0.0.0` 会让同一网络中的设备访问 API。请保留强密钥;离开可信局域网时,应使用 Tailscale 或 HTTPS 反向代理,不要直接把端口暴露到互联网。
|
||||
|
||||
### 3. 在手机上连接
|
||||
|
||||
打开应用后,可以:
|
||||
|
||||
- 扫描局域网中的 Hermes;
|
||||
- 手动输入 `http://<主机>:8642` 和 API 密钥;
|
||||
- 扫描包含 API、Dashboard 和可选 Relay 地址的设置二维码。
|
||||
|
||||
如需在手机上管理模型、密钥、技能和配置文件,请运行 Hermes Dashboard,并在应用的 **管理** 页面登录一次。同一登录会话也会启用标准语音。
|
||||
|
||||
### 4. 可选:安装 Relay
|
||||
|
||||
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音或电脑工具时安装:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 参与翻译
|
||||
|
||||
Android 英文资源是规范来源。新增语言必须保持资源名称、类型和格式参数一致,并通过:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
翻译规范、目录命名、复数和占位符规则见 [docs/localization.md](docs/localization.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) — Copyright (c) 2026 [Axiom-Labs](https://codename-11.dev)
|
||||
@@ -13,23 +13,24 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
|
||||
Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
||||
for automation.
|
||||
Hermes-Relay ships three independently versioned production surfaces. Public
|
||||
GitHub Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
|
||||
the corresponding build and deployment lane.
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
|
||||
| Hermes-Relay-Server | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-Desktop | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
|
||||
|
||||
This split is intentional. The plugin carries relay features for both Android
|
||||
and CLI clients, so plugin fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||
Android releases before this naming split used bare `v*` tags. Historical
|
||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
||||
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
|
||||
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
|
||||
new releases use the canonical prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -87,7 +88,7 @@ lockstep:
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Plugin releases via:
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
@@ -105,16 +106,50 @@ Check all release tracks at once with:
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, plugin, and CLI versions
|
||||
This aggregate check reports Android, Server, and Desktop versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `plugin-v*` release workflow validates the tag against the same metadata,
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs plugin tests, builds a wheel and sdist, generates checksums, and
|
||||
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
|
||||
publishes a `Hermes-Relay-Server vX.Y.Z` GitHub Release with the package
|
||||
artifacts.
|
||||
|
||||
### CLI / tray versioning
|
||||
|
||||
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
|
||||
must match the generated CLI and native Windows systray metadata. The systray is
|
||||
a menu-only controller for the installed CLI; it has no application window,
|
||||
WebView, embedded terminal, or separate desktop product surface. The public
|
||||
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
|
||||
optional Windows installer.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `desktop/package.json` | canonical CLI version |
|
||||
| `desktop/package-lock.json` | npm root/workspace package metadata |
|
||||
| `desktop/src/version.ts` | compiled CLI runtime version |
|
||||
| `desktop/tray/Cargo.toml` | native systray package version |
|
||||
| `desktop/tray/Cargo.lock` | locked systray package version |
|
||||
|
||||
Prepare a new CLI version on `dev` without creating a tag or npm-generated
|
||||
commit:
|
||||
|
||||
```powershell
|
||||
cd desktop
|
||||
npm version --no-git-tag-version 0.4.0-alpha.2
|
||||
npm run check:version-sync
|
||||
npm run verify
|
||||
```
|
||||
|
||||
The npm `version` lifecycle runs `sync:version`, which copies the canonical
|
||||
version into the generated CLI and tray metadata. If `package.json` was edited
|
||||
manually, run `npm run sync:version` before checking. `npm run verify` is the
|
||||
single Windows release-parity gate: version sync, type-check, tests, TypeScript
|
||||
build, compiled CLI smoke, and tray formatting, Clippy, check, and tests. CI runs
|
||||
the portable portions on every desktop change and the Windows tray gates separately.
|
||||
|
||||
## Branching policy
|
||||
|
||||
> **Updated 2026-04-19:** moved from `main`-only to `main + dev`. See
|
||||
@@ -132,12 +167,28 @@ the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
then tagging `main`. Feature completion means merged and verified on `dev`; it
|
||||
does not mean released.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
|
||||
an immutable release-candidate tag to staging. Record that source in the Forge
|
||||
release issue/session. Never deploy a moving branch name as the source of record
|
||||
and never create a staging branch. Production deploys only immutable
|
||||
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
|
||||
|
||||
### Normal contribution and release flow
|
||||
|
||||
1. Branch `feature/*`, `fix/*`, `docs/*`, or `chore/*` from `dev`.
|
||||
2. Open the PR into `dev` and require CI to pass.
|
||||
3. Merge with a merge commit/no-ff according to repository policy.
|
||||
4. Accumulate user-facing work under `CHANGELOG.md` `[Unreleased]`.
|
||||
5. Treat the feature as complete when it is merged and verified on `dev`.
|
||||
6. Start a separate Forge release issue/session when a release train is approved.
|
||||
7. Prepare the affected surface release on `dev`, including its version and notes.
|
||||
8. Open and approve the release PR from `dev` into `main`.
|
||||
9. Tag the new `main` tip with the affected surface prefix.
|
||||
10. Build and publish that surface's artifacts, roll out or deploy from the
|
||||
immutable tag, and verify the release and live environment.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -177,23 +228,35 @@ version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
|
||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use
|
||||
`release(desktop): desktop-vX.Y.Z`. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled:
|
||||
Repository files define the contract and CI, but GitHub owns the default branch,
|
||||
branch protection, rulesets, allowed merge methods, and required-check settings.
|
||||
Those settings require an operator or infrastructure automation.
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Plugin) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
The intended settings are:
|
||||
|
||||
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||
and deletion blocked. Normal work does not target this branch.
|
||||
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||
blocked. This is the normal contribution target.
|
||||
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
|
||||
the no-ff contract cannot be bypassed in the GitHub UI.
|
||||
- **Default branch** — `main`, which remains the release-history branch and the
|
||||
repository's canonical landing page. Normal contribution PRs must explicitly
|
||||
target `dev`.
|
||||
|
||||
As of the 2026-07-15 repository audit, the default branch was correctly `main`.
|
||||
The remaining GitHub-owned gaps were that `dev` had no protection, squash and
|
||||
rebase merges were enabled, and `main` protection did not apply to
|
||||
administrators. Those settings must be reconciled separately; this documentation
|
||||
PR does not mutate them.
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -354,6 +417,15 @@ tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release train ownership
|
||||
|
||||
Every release train gets its own Forge release issue/session. That owner records
|
||||
the exact tested staging source, reconciles the affected surface version and
|
||||
notes on `dev`, owns the `dev` → `main` PR, tags the new `main` tip, observes the
|
||||
artifact workflow, performs the rollout or deployment, and captures live
|
||||
verification. Feature implementation sessions stop at merged and verified on
|
||||
`dev`; they do not inherit release authority.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the Android app version
|
||||
@@ -392,15 +464,36 @@ the new app version and a higher `appVersionCode`.
|
||||
3. Skim the new versioned block and tighten / reorder if needed —
|
||||
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
|
||||
already be in place from the accumulator phase.
|
||||
4. **Per-surface split.** `[Unreleased]` accumulates entries from *all
|
||||
three* surfaces (Android + CLI + plugin), but releases are
|
||||
per-surface. Move only the entries for the surface you're cutting into
|
||||
the new versioned block, and leave the other surfaces' entries under
|
||||
the fresh `[Unreleased]` for their own `desktop-v*` / `server-v*` cut.
|
||||
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
|
||||
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
||||
historical record.)
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
**Download** section near the top, in the required format (#144):
|
||||
1. A lead callout naming the **one file most people want** —
|
||||
"Installing on your phone? Download
|
||||
`hermes-relay-<version>-sideload-release.apk` and tap it"
|
||||
(full feature set), with the Play Store link for the
|
||||
conservative build.
|
||||
2. One explicit line that the `.aab` is a Play Console upload
|
||||
bundle and **cannot** be installed by tapping it on a phone.
|
||||
3. The `SHA256SUMS.txt` verify line + sideload-guide link.
|
||||
No download table, no parity/testing artifacts: releases attach
|
||||
exactly **two** app artifacts — the sideload APK and the googlePlay
|
||||
AAB — plus `SHA256SUMS.txt` covering exactly those two (the 2-asset
|
||||
policy in `.github/workflows/release-android.yml`; the parity twins
|
||||
stay reproducible from the tag via CI but are not attached).
|
||||
Every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
@@ -464,11 +557,41 @@ prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\sideload\release\hermes-relay-*-sideload-release.apk`.
|
||||
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
### 4. Run the private Play preflight from `dev`
|
||||
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
The release-prep commit lands on `dev` first. Before any public tag or GitHub
|
||||
Release exists, open **Actions → Play Preflight — Android**, choose **Run
|
||||
workflow**, select the final `dev` branch, and enter the prepared version.
|
||||
|
||||
The preflight workflow:
|
||||
|
||||
1. requires the workflow to run from `dev` or untagged `main` with matching
|
||||
version metadata;
|
||||
2. runs the release metadata, locale, and Android collection-API checks;
|
||||
3. builds and release-signs the same APK/AAB variants used by the public release;
|
||||
4. scans the final minified APK DEX for unsupported collection calls;
|
||||
5. uploads the Google Play AAB as a private **Production draft**; and
|
||||
6. records a 30-day preflight proof keyed to the version and Git tree hash.
|
||||
|
||||
No sideload APK or GitHub Release is published by preflight. A successful signed
|
||||
build, final DEX scan, and Production-draft upload is the automated Play release
|
||||
gate. Play Console pre-review and pre-launch reports are informational and
|
||||
non-blocking because their detailed results are not exposed through the release
|
||||
automation API. If the release source changes after preflight, rerun it—the
|
||||
approval workflow matches the complete Git tree, not just the version number.
|
||||
|
||||
GitHub exposes manual workflows only after their workflow file exists on the
|
||||
default branch. For the first release that introduces this process, merge the
|
||||
release PR without creating a tag, run preflight from untagged `main`, and then
|
||||
use the approval workflow. This publishes no app artifacts before the automated
|
||||
Play upload gate.
|
||||
|
||||
### 5. Merge to `main` and approve the public release
|
||||
|
||||
After Play preflight passes, merge the release PR from `dev` to `main`
|
||||
with `--no-ff`. The merge commit may differ from the preflight commit, but its
|
||||
tree must be identical. If the merge changes the tree, rerun private preflight
|
||||
from untagged `main`:
|
||||
|
||||
```bash
|
||||
# From a clean dev checkout:
|
||||
@@ -480,29 +603,38 @@ git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Run Play Preflight — Android from dev and require a successful workflow.
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
Then open **Actions → Approve Android Release**, choose **Run workflow**, select
|
||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||
verifies that `main` has the exact preflighted tree and creates the
|
||||
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||
another workflow, approval dispatches the current release workflow definition
|
||||
from `main`; every release job explicitly checks out and verifies the immutable
|
||||
`android-v<version>` tag. This lets release-workflow fixes apply without moving
|
||||
an existing tag or changing its artifact tree. Manual stable tags are still
|
||||
guarded by the same preflight proof in the tag workflow.
|
||||
|
||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||
it for review), and only after Play accepts that operation creates the public
|
||||
GitHub Release with the sideload APK. A missing preflight, changed release tree,
|
||||
missing Play credential, or Play submission failure prevents public GitHub
|
||||
publication.
|
||||
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
|
||||
### Plugin / Python package release
|
||||
### Server / Python package release
|
||||
|
||||
Use this when plugin or relay behavior changes independently of Android app
|
||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||
voice auth, dashboard plugin UI, or packaging changes.
|
||||
|
||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
||||
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
`server-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
|
||||
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
|
||||
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
|
||||
@@ -513,33 +645,80 @@ git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md PLUGIN_RELEASE_NOTES.md
|
||||
git commit -m "release(plugin): plugin-v0.6.2"
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag plugin-v0.6.2
|
||||
git push origin plugin-v0.6.2
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
Pushing `server-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
validates all plugin-owned version metadata with
|
||||
`scripts/check-plugin-version-sync.py`. Run
|
||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||
touches more than one release surface. The workflow also runs plugin tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
|
||||
Release named `Hermes-Relay-Server v<version>` for the server/plugin package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
### CLI / Windows systray release
|
||||
|
||||
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
|
||||
> automated for stable tags.** The release workflow runs
|
||||
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
|
||||
> Production **draft** — skip to the Play Console, confirm the draft, and click
|
||||
> **Start rollout**. The manual path below is the fallback when the secret is
|
||||
> unset (or for staging on a non-production track).
|
||||
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
|
||||
Android and plugin versions do not need to move with it.
|
||||
|
||||
First rewrite `CLI_RELEASE_NOTES.md` for the new Desktop release and promote only
|
||||
CLI/tray-relevant changelog bullets into the release block. Then:
|
||||
|
||||
```powershell
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
cd desktop
|
||||
npm version --no-git-tag-version 0.4.0-alpha.2
|
||||
npm run verify
|
||||
cd ..
|
||||
|
||||
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
|
||||
desktop/tray/Cargo.toml desktop/tray/Cargo.lock CHANGELOG.md CLI_RELEASE_NOTES.md
|
||||
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from main:
|
||||
git switch main
|
||||
git pull --ff-only origin main
|
||||
cd desktop
|
||||
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
||||
cd ..
|
||||
git tag desktop-v0.4.0-alpha.2
|
||||
git push origin desktop-v0.4.0-alpha.2
|
||||
```
|
||||
|
||||
The tag workflow rejects version drift and tags whose commit is not in
|
||||
`origin/main`, reruns CLI tests, builds all four standalone binaries, tests and
|
||||
packages the Windows tray, generates checksums, and publishes the GitHub Release.
|
||||
|
||||
### 6. Play review and publishing behavior
|
||||
|
||||
> **Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`.** Preflight
|
||||
> uploads the Production draft; approval promotes that same version code to
|
||||
> `completed`. Play Console-only reports are informational and non-blocking.
|
||||
> Stable releases do not fall back to publishing GitHub first when Play
|
||||
> credentials or submission are unavailable.
|
||||
>
|
||||
> This automated path is intentionally bundle-only. It uploads the
|
||||
> `googlePlayRelease` AAB and release-scoped "What's new" notes, but it does
|
||||
> not republish static listing assets such as screenshots, title, description,
|
||||
> icon, or feature graphic. Use the Play Store Listing workflow when those
|
||||
> assets change.
|
||||
|
||||
If Play Console **Managed publishing** is enabled, an approved submission remains
|
||||
under **Changes ready to publish** until a Play Console user publishes it. If it
|
||||
is disabled, the production submission may become available after Google review.
|
||||
Either behavior begins only after the public-release approval described above.
|
||||
|
||||
**Pick the track first.** The AAB is track-agnostic — the same
|
||||
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
|
||||
@@ -587,7 +766,7 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 6. Tracks (a menu, not a mandatory ladder)
|
||||
### 7. Tracks (a menu, not a mandatory ladder)
|
||||
|
||||
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
|
||||
stable GA publishes **straight to Production** — there is no required promotion
|
||||
@@ -606,7 +785,7 @@ the Play Console UI or:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
|
||||
### 7. After release
|
||||
### 8. After release
|
||||
|
||||
- Verify the GitHub Release has APK, AAB, and `SHA256SUMS.txt` attached.
|
||||
- Confirm the release body includes the **Download** section that tells
|
||||
@@ -631,39 +810,46 @@ plugin changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
1. Verifies the stable tag resolves to a commit contained in `main` and that the
|
||||
tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
4. Builds all four flavored release artifacts
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached (see §Release assets).
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. Promotes the exact preflighted Production draft to `completed`; a missing
|
||||
credential or rejected Play edit fails before public GitHub publication.
|
||||
7. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
|
||||
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
1. Verifies the tag commit is contained in `main`, validates the tag against
|
||||
all server/plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`, and requires the matching release
|
||||
heading in `CHANGELOG.md`.
|
||||
2. Runs plugin syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `cli-v*`,
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
|
||||
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
|
||||
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
|
||||
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
|
||||
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
|
||||
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
|
||||
whose commit is not contained in `main`, whose version differs from
|
||||
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
|
||||
Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
@@ -678,19 +864,28 @@ in the built bundle.
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
|
||||
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | Stable Play submission | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
|
||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
||||
and you upload manually (§5) — nothing else changes.
|
||||
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
|
||||
the Production draft and the tag workflow promotes that exact version code to
|
||||
`completed`. The workflow does not fall back to manual upload or publish GitHub
|
||||
first. With Play Managed Publishing off, an approved release publishes
|
||||
automatically; with it on, Play holds the approved change for an operator action
|
||||
that the Developer API does not expose.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
When production has a bug, use the same invariant for every surface:
|
||||
|
||||
1. Branch from the affected immutable `android-v*`, `server-v*`, or `desktop-v*`
|
||||
production tag, never from the moving `main` or `dev` branch.
|
||||
2. Make the smallest safe fix and add focused verification.
|
||||
3. Bump only the affected surface's patch version and release notes.
|
||||
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
|
||||
5. Tag the new `main` tip with the affected surface's patch tag.
|
||||
6. Verify the artifacts and production rollout or deployment.
|
||||
7. Merge `main` back into `dev` immediately so integration inherits the fix and
|
||||
version history.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -704,17 +899,23 @@ For an Android app hotfix:
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
7. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, and tag `plugin-v<next-version>`. Do not touch
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a Desktop hotfix, branch from the affected `desktop-v*` tag, update only
|
||||
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
|
||||
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
|
||||
then merge `main` back to `dev`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
|
||||
@@ -1,55 +1,39 @@
|
||||
# Hermes-Relay-Android v1.1.0
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
|
||||
**Release Date:** June 16, 2026
|
||||
**Since v1.0.0:** A settings + chat-UX overhaul — quieter status surfaces, a single state-aware plugin badge, and chat-settings polish — plus a force-close fix and release-pipeline upgrades.
|
||||
|
||||
v1.1.0 is a refinement release on top of the 1.0 milestone. Settings is calmer and easier to read: status pills now appear only when a surface needs attention, the Power tools section shows one **Plugin active / required / offline** badge instead of an identical chip on every card, and the most-used controls sit where you reach for them. Chat settings render correctly, the system-prompt preview reflects your toggles, and a crash that could hit right after a successful pair is gone.
|
||||
|
||||
---
|
||||
**Release Date:** July 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
v1.1.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.1.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.1.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.1.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.1.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
|
||||
|
||||
---
|
||||
## Summary
|
||||
|
||||
## Highlights
|
||||
This release makes the Hermes Dashboard and Gateway the clear standard connection, keeps active work reachable across the app and Android backgrounding, and gives profiles, voice, attachments, image generation, and approvals a more coherent native interface.
|
||||
|
||||
### Settings screen overhaul
|
||||
## Added
|
||||
|
||||
Settings was reorganized around what you actually touch and quieted down everywhere else:
|
||||
- The Agent Passport drawer combines live route and session context with explicit profile switching, personality, model, reasoning, approval-policy, chat-override, and processing-tier controls.
|
||||
- Secure browser-based Dashboard sign-in is scoped to the selected host. Chat, sessions, Manage, and Standard Voice share the same authenticated Gateway route while the API server remains an automatic fallback.
|
||||
- Standard and Realtime voice settings use focused provider, model, and voice cards with upstream-aware discovery, descriptions, inline previews, waveforms, and a browsable catalog. Standard replies can begin speaking completed segments before generation finishes.
|
||||
- User-started turns stay protected until every concurrent session settles. Privacy-safe notifications reopen the correct chat for approvals, questions, elevated permissions, or secure responses.
|
||||
- Onboarding finishes with a layered permission review: notifications are recommended deliberately, optional capabilities remain separate, and users can continue without granting phone access.
|
||||
- Chat surfaces one-turn model choices, approval modes, advisor progress, queued recovery, project labels, collapsible attachments, persisted images, interim Gateway events, and image-generation activity.
|
||||
|
||||
- **Exception-only status pills.** Status pills now appear only when a surface needs attention and stay quiet when everything is healthy — no more a wall of green chips to read past.
|
||||
- **One state-aware plugin badge.** The Power tools section shows a single **Plugin active / required / offline** badge instead of an identical "Relay paired" chip repeated on every card.
|
||||
- **Layout that follows your reach.** Connections moved to the top (above the Hermes section), and Diagnostics + Developer options moved into the App section.
|
||||
- **Restyled to match the app.** The status chips now use the app's translucent-bordered language, and the brand blue was deepened.
|
||||
## Fixed
|
||||
|
||||
### Chat settings polish
|
||||
- Gateway reconnects reactivate the original live session without resubmitting acknowledged prompts or duplicating session rows.
|
||||
- Tailscale, QR, and other remote routes move Dashboard, Gateway, sessions, Manage, Standard Voice, API fallback, and optional Relay together.
|
||||
- Dashboard authentication, model routing, recovery, and profile state stay scoped to the selected connection and session, including during cold start and rapid switching.
|
||||
- Promoted voice and background tasks keep their owning Chat row until the work settles.
|
||||
- User-installed certificate authorities work for self-hosted HTTPS/WSS while normal chain, hostname, and Relay-pin verification remain enforced.
|
||||
- Malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- Developer Options no longer exposes the obsolete Relay feature flag; version-tap unlock, relock, backup, import, and reset actions now persist and report accurately.
|
||||
|
||||
- **Streaming-endpoint picker fixed.** The picker no longer wraps "Gateway" / "Sessions" onto a second line.
|
||||
- **Live system-prompt preview.** The system-prompt preview now reflects the context toggles you've enabled (foreground app, battery, safety rails) with representative placeholder values, instead of looking inert.
|
||||
## Install / Verify
|
||||
|
||||
### Force-close fix
|
||||
|
||||
A corrupt encrypted token store — which can happen after an app upgrade or a device restore — used to throw during construction and crash the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset in place, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
|
||||
### Release pipeline
|
||||
|
||||
- **Automated Play Console upload.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, releases publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
- The force-close fix means devices that previously crashed on connect after an upgrade or restore will heal their token store automatically on first launch of this build — no manual re-pair required in most cases.
|
||||
- `appVersionCode` is **13**.
|
||||
- App version: **1.5.0** (versionCode **33**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -101,7 +101,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
|
||||
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
|
||||
|
||||
@@ -109,7 +109,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).** Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless and statefulness lives on `/api/sessions/*`.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Security Policy
|
||||
|
||||
Hermes-Relay can give a remote AI agent real control of a phone and, via the
|
||||
CLI, of a paired desktop. We take security reports seriously and welcome
|
||||
responsible disclosure.
|
||||
|
||||
For the architecture, threat model, and the `googlePlay` vs. `sideload`
|
||||
capability boundary, see [`docs/security.md`](docs/security.md). This document
|
||||
covers **how to report a problem**.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue, discussion, or pull request for a security
|
||||
vulnerability.** Public reports expose users before a fix is available.
|
||||
|
||||
Use one of these private channels instead:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting (preferred).** Go to the
|
||||
repository's **Security** tab → **Report a vulnerability**, or
|
||||
[open a draft advisory directly](https://github.com/Codename-11/hermes-relay/security/advisories/new).
|
||||
This keeps the whole exchange private and threaded with the code.
|
||||
2. **Email** — `security@codename-11.dev`. Use this if you can't use GitHub.
|
||||
If you'd like to encrypt the report, say so in a first contact message and
|
||||
we'll arrange a key.
|
||||
|
||||
### What to include
|
||||
|
||||
A good report lets us reproduce and assess impact quickly:
|
||||
|
||||
- The affected surface — **Android app** (and which flavor, `googlePlay` or
|
||||
`sideload`), **relay plugin / server**, **desktop CLI**, or the **docs site**.
|
||||
- Affected version(s) — app version/code, plugin version, or CLI version.
|
||||
- A clear description of the issue and its security impact.
|
||||
- Step-by-step reproduction, a proof of concept, or a minimal example.
|
||||
- Any suggested remediation, if you have one.
|
||||
|
||||
> ⚠️ **Scrub secrets before sending.** Remove API keys, relay session tokens,
|
||||
> pairing codes, real hostnames/IPs, and personal data from logs, traces, and
|
||||
> screenshots.
|
||||
|
||||
## What to Expect
|
||||
|
||||
This is an indie, open-source project, so timelines are best-effort rather than
|
||||
contractual:
|
||||
|
||||
- **Acknowledgement** of your report — typically within **5 business days**.
|
||||
- An initial **assessment and severity triage** after we can reproduce it.
|
||||
- **Coordinated disclosure:** we'll work with you on a fix and a disclosure
|
||||
timeline, and credit you in the advisory and release notes if you'd like
|
||||
(or keep you anonymous if you prefer).
|
||||
- A public GitHub Security Advisory and a `CHANGELOG.md` entry once a fix ships.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope** — vulnerabilities in code this project ships:
|
||||
|
||||
- The Android app (`app/`) on either flavor.
|
||||
- The relay plugin and server (`plugin/`).
|
||||
- The desktop CLI (`desktop/`).
|
||||
- The pairing, auth, transport, media, and tool-routing surfaces.
|
||||
|
||||
**Out of scope** — please report these to the right place instead:
|
||||
|
||||
- **Your own Hermes server configuration** (missing TLS, an exposed dashboard,
|
||||
weak provider keys). The relay connects only to endpoints you configure; how
|
||||
you deploy and secure your Hermes host is outside this app. See
|
||||
[`docs/security.md`](docs/security.md) and the relay-server docs for hardening
|
||||
guidance.
|
||||
- **Upstream [hermes-agent](https://github.com/NousResearch/hermes-agent)**
|
||||
issues — report those to the upstream project (a heads-up to us is welcome if
|
||||
it affects how Hermes-Relay should behave).
|
||||
- **Third-party dependencies** — report upstream; if a dependency issue affects
|
||||
Hermes-Relay users, tell us so we can pin or patch.
|
||||
- Findings that require a **rooted device, a physical-access attacker, or a
|
||||
malicious app already granted Accessibility/overlay permissions** — these are
|
||||
outside the model documented in `docs/security.md`, though we'll still read
|
||||
the report.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
We consider security research conducted in good faith under this policy to be
|
||||
authorized. We will not pursue or support legal action against researchers who:
|
||||
|
||||
- Make a good-faith effort to avoid privacy violations, data destruction, and
|
||||
service disruption.
|
||||
- Test only against **their own devices, installs, and Hermes servers** — never
|
||||
another person's data or infrastructure.
|
||||
- Report promptly and give us a reasonable chance to remediate before any
|
||||
public disclosure.
|
||||
|
||||
Thank you for helping keep Hermes-Relay and its users safe.
|
||||
@@ -27,7 +27,7 @@ android {
|
||||
// and `applicationId` is the runtime install identity; they don't have
|
||||
// to match.
|
||||
namespace = "com.hermesandroid.relay"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
// Axiom-Labs, LLC Play Console listing. Changed from the original
|
||||
@@ -179,6 +179,16 @@ android {
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
// [POC] Roborazzi runs without its Gradle plugin (the plugin needs AGP's
|
||||
// removed TestedExtension). Force record mode via the test-JVM system
|
||||
// property the plugin would otherwise inject, so captureRoboImage writes.
|
||||
// Heap: the Roborazzi store renders (1080×2160 native graphics) share a
|
||||
// worker JVM with the Robolectric suites; Gradle's 512m default OOMs
|
||||
// once both are in the same run.
|
||||
unitTests.all {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +208,17 @@ kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
// [screenshots] Host-side screenshot tests render MessageBubble -> MarkdownContent,
|
||||
// whose code-highlighter (dev.snipme.highlights) ships Java-21 bytecode. The build
|
||||
// toolchain pins test execution to JDK 17, which can't load class-file v65, so run
|
||||
// unit tests on a 21 JVM. Compile target stays 17; on-device (dexed) is unaffected.
|
||||
// foojay (settings.gradle.kts) auto-provisions the 21 JDK if absent.
|
||||
tasks.withType<Test>().configureEach {
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }
|
||||
)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
val composeBom = platform(libs.compose.bom)
|
||||
@@ -224,6 +245,8 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
@@ -239,6 +262,15 @@ dependencies {
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
// Scoped via the `googlePlayImplementation` configuration so it never
|
||||
// ships in the sideload APK, which updates via the GitHub-releases
|
||||
// UpdateChecker instead. The `app/src/googlePlay/.../update/` impl
|
||||
// references AppUpdateManager; the `app/src/sideload/.../update/` impl
|
||||
// never touches this library.
|
||||
"googlePlayImplementation"(libs.play.app.update)
|
||||
"googlePlayImplementation"(libs.play.app.update.ktx)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
@@ -266,6 +298,9 @@ dependencies {
|
||||
|
||||
// Security
|
||||
implementation(libs.security.crypto)
|
||||
// Force a Tink newer than security-crypto's transitive one — older Tink's
|
||||
// HybridConfig removeFirst()/removeLast() trips the Android-15 crash lint.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
@@ -288,5 +323,14 @@ dependencies {
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Local device-review helper. Never runs in or ships with the application APK. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConnectionReviewSeedTest {
|
||||
|
||||
@Test
|
||||
fun seedOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.none { it.id == REVIEW_ID }) {
|
||||
store.addConnection(
|
||||
Connection(
|
||||
id = REVIEW_ID,
|
||||
label = "Lab NAS",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
|
||||
dashboardUrl = "http://192.0.2.10:9119",
|
||||
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.any { it.id == REVIEW_ID }) {
|
||||
store.removeConnection(REVIEW_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
|
||||
}
|
||||
}
|
||||
@@ -121,42 +121,39 @@ class OnboardingFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
fun connectPage_showsNearbyFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Standard Hermes")
|
||||
.onNodeWithText("Enter address instead")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_showsApiFields() {
|
||||
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.onNodeWithText("Hermes address")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
fun manualSetup_findButton_isShown() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Standard Hermes").performClick()
|
||||
composeTestRule.onNodeWithText("Enter address instead").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
.onNodeWithText("Find Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -164,6 +161,9 @@ class OnboardingFlowTest {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Other connection methods").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
@@ -172,6 +172,17 @@ class OnboardingFlowTest {
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun powerPage_linksToPermissionReview() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(3)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Review permissions")
|
||||
.assertIsDisplayed()
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PermissionsStatusScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun permissionsScreen_showsStandardAndOnDemandRows() {
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
PermissionsStatusScreen(
|
||||
onBack = {},
|
||||
onOpenBridge = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Permissions and capabilities")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat and Manage")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("No Android runtime permission needed. API/dashboard auth is configured separately.")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Camera")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Microphone")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,199 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.play.core.appupdate.AppUpdateInfo
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.appupdate.AppUpdateOptions
|
||||
import com.google.android.play.core.install.InstallState
|
||||
import com.google.android.play.core.install.InstallStateUpdatedListener
|
||||
import com.google.android.play.core.install.model.AppUpdateType
|
||||
import com.google.android.play.core.install.model.InstallStatus
|
||||
import com.google.android.play.core.install.model.UpdateAvailability
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* === update (googlePlay flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto Google Play's In-App Update API,
|
||||
* FLEXIBLE flow. Mirrors `voice/VoiceBridgeIntentFactory`'s flavor-split
|
||||
* factory pattern: both flavors export this exact function signature +
|
||||
* package, so the UI layer has one static call site and no reflection / no
|
||||
* `#if` gating.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
PlayUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "PlayUpdate"
|
||||
|
||||
/**
|
||||
* Google Play FLEXIBLE in-app update source.
|
||||
*
|
||||
* - [check] queries `AppUpdateManager.appUpdateInfo`. If Play reports
|
||||
* `UPDATE_AVAILABLE` and FLEXIBLE is allowed, returns [UpdateStatus.Available]
|
||||
* (or [UpdateStatus.Downloaded] / [UpdateStatus.Downloading] if a previously
|
||||
* started flexible update is already mid-flight). Anything else →
|
||||
* [UpdateStatus.UpToDate].
|
||||
* - [startUpdate] launches Play's FLEXIBLE consent + background download and
|
||||
* registers an [InstallStateUpdatedListener] so DOWNLOADED is reported back
|
||||
* asynchronously via [onStatusChanged].
|
||||
* - [completeUpdate] calls `AppUpdateManager.completeUpdate()` which restarts
|
||||
* the app to install the staged APK.
|
||||
*
|
||||
* Robustness: every Play interaction is wrapped in try/catch. On any failure
|
||||
* (no Play services, sideloaded "googlePlay" build on an AOSP device, RESULT
|
||||
* errors) it degrades to [UpdateStatus.UpToDate] / [UpdateStatus.Unsupported]
|
||||
* — the banner just never shows. Play is never a crash surface.
|
||||
*/
|
||||
private class PlayUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
private val manager: AppUpdateManager? = runCatching {
|
||||
AppUpdateManagerFactory.create(appContext)
|
||||
}.getOrNull()
|
||||
|
||||
/** Cached label/code from the last [check] so async listener events can label themselves. */
|
||||
@Volatile private var lastVersionCode: Long? = null
|
||||
|
||||
private val installListener = InstallStateUpdatedListener { state: InstallState ->
|
||||
when (state.installStatus()) {
|
||||
InstallStatus.DOWNLOADING ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
// bytesDownloaded()/totalBytesToDownload() are base
|
||||
// app-update InstallState methods (Long); no ktx import.
|
||||
bytesDownloaded = state.bytesDownloaded(),
|
||||
totalBytes = state.totalBytesToDownload(),
|
||||
)
|
||||
)
|
||||
InstallStatus.DOWNLOADED ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
)
|
||||
else -> Unit // INSTALLING / INSTALLED / FAILED / CANCELED → no banner change
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var listenerRegistered = false
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
val mgr = manager ?: return UpdateStatus.Unsupported
|
||||
return try {
|
||||
val info = mgr.awaitAppUpdateInfo()
|
||||
lastVersionCode = info.availableVersionCode().toLong()
|
||||
when {
|
||||
// A previously started FLEXIBLE update already finished downloading.
|
||||
info.installStatus() == InstallStatus.DOWNLOADED -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
|
||||
info.installStatus() == InstallStatus.DOWNLOADING -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
|
||||
UpdateStatus.Available(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
openUrl = null,
|
||||
)
|
||||
else -> UpdateStatus.UpToDate
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "appUpdateInfo check failed; treating as up-to-date", t)
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val mgr = manager ?: return false
|
||||
if (activity == null) return false
|
||||
return try {
|
||||
ensureListener(mgr)
|
||||
mgr.appUpdateInfo
|
||||
.addOnSuccessListener { info: AppUpdateInfo ->
|
||||
val canStart = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
|
||||
val resuming = info.updateAvailability() ==
|
||||
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
|
||||
if (canStart || resuming) {
|
||||
runCatching {
|
||||
mgr.startUpdateFlow(
|
||||
info,
|
||||
activity,
|
||||
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "startUpdateFlow failed", it) }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { Log.w(TAG, "startUpdate appUpdateInfo failed", it) }
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun completeUpdate() {
|
||||
val mgr = manager ?: return
|
||||
runCatching { mgr.completeUpdate() }
|
||||
.onFailure { Log.w(TAG, "completeUpdate failed", it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val mgr = manager ?: return
|
||||
if (listenerRegistered) {
|
||||
runCatching { mgr.unregisterListener(installListener) }
|
||||
listenerRegistered = false
|
||||
}
|
||||
onStatusChanged = null
|
||||
}
|
||||
|
||||
private fun ensureListener(mgr: AppUpdateManager) {
|
||||
if (!listenerRegistered) {
|
||||
runCatching { mgr.registerListener(installListener) }
|
||||
.onSuccess { listenerRegistered = true }
|
||||
.onFailure { Log.w(TAG, "registerListener failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Play exposes only the numeric versionCode, not a marketing version
|
||||
// string, so the banner copy stays generic ("A new version"). The code is
|
||||
// still carried on the status for per-version dismissal keying.
|
||||
private fun labelFor(@Suppress("UNUSED_PARAMETER") code: Long?): String = "A new version"
|
||||
}
|
||||
|
||||
// === END update (googlePlay) ===
|
||||
|
||||
/**
|
||||
* `await()` for Play's [AppUpdateInfo] task without pulling in
|
||||
* `kotlinx-coroutines-play-services`. Named `await…` (not the ktx
|
||||
* `requestAppUpdateInfo`) to avoid any overload ambiguity with the
|
||||
* `app-update-ktx` suspend extension. Resumable + cancels cleanly if the
|
||||
* coroutine is torn down.
|
||||
*/
|
||||
private suspend fun AppUpdateManager.awaitAppUpdateInfo(): AppUpdateInfo =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
appUpdateInfo
|
||||
.addOnSuccessListener { info -> if (cont.isActive) cont.resume(info) }
|
||||
.addOnFailureListener { e -> if (cont.isActive) cont.cancel(e) }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
info@axiom-labs.dev
|
||||
@@ -0,0 +1 @@
|
||||
en-US
|
||||
@@ -0,0 +1,62 @@
|
||||
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
|
||||
|
||||
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
|
||||
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
|
||||
|
||||
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
|
||||
|
||||
GOOGLE PLAY BUILD
|
||||
|
||||
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
|
||||
FEATURES
|
||||
|
||||
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
|
||||
|
||||
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
|
||||
|
||||
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
|
||||
|
||||
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
|
||||
|
||||
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
|
||||
|
||||
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
|
||||
|
||||
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
|
||||
|
||||
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
|
||||
|
||||
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
|
||||
|
||||
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
- API keys and relay tokens are stored in encrypted Android storage.
|
||||
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
|
||||
- No telemetry, ads, tracking, or third-party analytics SDKs.
|
||||
- Notification access and the microphone are optional and user-controlled.
|
||||
- All app traffic goes only to servers you configure.
|
||||
|
||||
REQUIREMENTS
|
||||
|
||||
- Android 8.0 or later.
|
||||
- A running Hermes agent for chat, management, and voice.
|
||||
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
|
||||
- Network access to your server by local network, VPN, or internet.
|
||||
|
||||
OPEN SOURCE
|
||||
|
||||
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
|
||||
|
||||
This app is a community project and is not affiliated with or endorsed by NousResearch.
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1 @@
|
||||
Your Hermes AI agent, in your pocket - chat, voice, and control.
|
||||
@@ -0,0 +1 @@
|
||||
Hermes-Relay
|
||||
@@ -1,5 +1 @@
|
||||
Settings & chat polish:
|
||||
• Status chips now show only when something needs attention; Power tools shows one live plugin badge; Connections moved to the top of Settings.
|
||||
• Chat settings: fixed the streaming-endpoint picker layout; the system-prompt preview now reflects your enabled toggles.
|
||||
• Fixed a rare crash on connect from a corrupt saved credential (now self-heals).
|
||||
• Server-side relay-plugin improvements.
|
||||
Connect through secure Dashboard sign-in, switch profiles from the new Agent Passport, and keep multiple background chats active with actionable approval and question alerts. Image generation, attachments, model routing, voice, and Gateway recovery are clearer and more reliable. Setup now guides optional notification, camera, microphone, and companion permissions without blocking chat.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
通过安全的 Dashboard 登录连接,并在新的智能体护照中切换配置文件。多个后台对话可保持运行,审批或提问通知可直接返回正确会话。图像生成、附件、模型路由、语音和 Gateway 恢复更加清晰可靠。设置流程会说明可选的通知、相机、麦克风和通知伴侣权限,且不会阻止聊天。
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -28,6 +29,7 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HermesRelay">
|
||||
@@ -36,7 +38,9 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:configChanges="uiMode|fontScale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
@@ -45,6 +49,17 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- AppCompat persists in-app language choices on Android 12 and lower.
|
||||
Android 13+ stores the same selection in the platform LocaleManager. -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -67,10 +82,18 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- Opt-in "Keep connected in background" — holds the gateway chat
|
||||
socket open while backgrounded. In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
<!-- Inline-reply receiver for proactive-message notifications
|
||||
(Phase 2c — two-way phone messaging). Not exported: it is only
|
||||
ever triggered by the app's own mutable RemoteInput PendingIntent
|
||||
delivered by the system, never by a third party. -->
|
||||
<receiver
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
@@ -78,7 +101,7 @@
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
|
||||
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
"date": "2026-07-19",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Dashboard-first setup",
|
||||
"bullets": [
|
||||
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
|
||||
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Consistent identity",
|
||||
"bullets": [
|
||||
"Server default now displays Hermes' pinned active profile consistently across the app.",
|
||||
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.8",
|
||||
"title": "Privacy policy restored",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Google Play compliance",
|
||||
"bullets": [
|
||||
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
|
||||
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.7",
|
||||
"title": "Smoother replies, more languages",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Smooth streaming",
|
||||
"bullets": [
|
||||
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
|
||||
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More languages",
|
||||
"bullets": [
|
||||
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
|
||||
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.6",
|
||||
"title": "Profiles stay together",
|
||||
"date": "2026-07-15",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One Server-default profile",
|
||||
"bullets": [
|
||||
"Server default now keeps the selected agent, session drawer, transcript, and new messages in Hermes' sticky active profile.",
|
||||
"Reorder or hide profiles per connection without changing server configuration."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Profile icons",
|
||||
"bullets": [
|
||||
"Choose an image through Android's file picker or import avatar.png/profile.jpg from an updated paired Relay.",
|
||||
"Host import now distinguishes an outdated Relay from a genuinely missing profile image."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"title": "Chats that keep running",
|
||||
"date": "2026-07-15",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Keep moving between chats",
|
||||
"bullets": [
|
||||
"Switch to another chat, profile, draft, or Thread without stopping a running Gateway reply.",
|
||||
"Return to the session and reattach to its live checkpoint and progress."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Cleaner live state",
|
||||
"bullets": [
|
||||
"Expired secret and sudo prompts collapse when Hermes reports their expiry, so stale actions no longer look usable.",
|
||||
"Provider wait, reconnect, and continuation notices stay in Chat's live status line instead of cluttering the conversation."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.4",
|
||||
"title": "Spanish and clearer diagnostics",
|
||||
"date": "2026-07-12",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Language that is ready to grow",
|
||||
"bullets": [
|
||||
"Use Spanish throughout the app from Settings → Appearance.",
|
||||
"Translation freshness checks flag catalogs whenever the English source changes, while fluent verification remains tracked separately."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Know what is connected",
|
||||
"bullets": [
|
||||
"Refresh Diagnostics to see the Relay plugin version, protocol, capability count, profile status, and last-check time.",
|
||||
"Open the complete release history directly from the cleaner What’s New modal."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.3",
|
||||
"title": "Language switching inside the app",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Language at your fingertips",
|
||||
"bullets": [
|
||||
"Choose System default, English, or Simplified Chinese from Settings → Appearance without leaving Hermes-Relay.",
|
||||
"The picker stays synchronized with Android's per-app language setting and persists the choice on Android 12 and lower.",
|
||||
"Release builds reject collection APIs that can crash on Android versions before API 35."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.2",
|
||||
"title": "Simplified Chinese and scalable localization",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Simplified Chinese throughout the app",
|
||||
"bullets": [
|
||||
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
|
||||
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Localization built to grow",
|
||||
"bullets": [
|
||||
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
|
||||
"Connection scan and queued-message counts now use locale-aware Android plurals."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.1",
|
||||
"title": "Chat that keeps up",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Chat that stays with you",
|
||||
"bullets": [
|
||||
"Follow background terminal work from a compact process strip and expandable sheet. Its completed answer appears in the same conversation automatically.",
|
||||
"Close and reopen while a reply runs: partial text, thinking, tool progress, background-task state, and pending approvals return in the same chat without repeating your prompt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice you can direct",
|
||||
"bullets": [
|
||||
"Use spoken commands to pause or resume listening, stop speech, cancel background work, repeat a finished result, or start Standard voice chat.",
|
||||
"Hands-free, Low latency, Careful tools, and Quiet presets tune existing voice behavior without changing your selected voice or route."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer conversations",
|
||||
"bullets": [
|
||||
"Browse adjacent images as a gallery, read smoother streaming Markdown and wide tables, and see background-process completion as a compact process notice."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"title": "Realtime voice that finishes the job",
|
||||
"date": "2026-07-09",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice that keeps going",
|
||||
"bullets": [
|
||||
"Quick follow-ups can be answered while a long Hermes task runs, another long request can wait in a bounded queue, and the finished answer can stay in the selected realtime voice.",
|
||||
"Voice route recovery now waits for relay confirmation, replays unacknowledged input without starting a second Hermes run, and rejects stale sockets or sessions before they can overwrite a healthy connection.",
|
||||
"Listening, thinking, reconnecting, and cancellation states now settle cleanly after Stop, exit, route loss, or terminal retry failure."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Models and phone automation",
|
||||
"bullets": [
|
||||
"Realtime Agent model and voice choices apply to the next session, persist per connection/profile, and survive restart.",
|
||||
"Chat and Manage can refresh dynamic provider model catalogs on demand.",
|
||||
"Opt-in notification rules can offer a local Ask Hermes action, and Bridge tools can target a specific paired Android device."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliability and safety",
|
||||
"bullets": [
|
||||
"Long chat turns avoid premature transport fallback, and supported voice, card, and attachment context now reaches upstream Hermes through channels it consumes.",
|
||||
"Malformed server addresses fail through normal connection errors, older Android versions avoid newer collection APIs, and relay media blocks credential and token paths.",
|
||||
"Model management keeps unconfigured providers visible with key-setup guidance, and session cleanup gains export, prune preview/apply, archive, and restore plumbing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"title": "Voice that multitasks & sturdier chats",
|
||||
"date": "2026-07-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice, hands-free",
|
||||
"bullets": [
|
||||
"Ask for something big and keep talking — long tasks hand off to the background with a live chip showing the current step, steps done, and a running timer, with a tap-to-cancel. The answer is spoken when it's ready, even after a brief disconnect — and if the voice session is gone, it arrives as a notification (the full answer is always in the chat).",
|
||||
"Leaving voice mode (or tapping stop to interrupt speech) no longer cancels a running background task — the chip's ✕ is the one deliberate kill switch, and a delivered answer keeps its text instead of flipping to \"Cancelled.\"",
|
||||
"Quieter and quicker: the agent speaks at milestones instead of narrating every step, clearly long tasks hand off to the background right away, and the first turn starts faster — the session warms up when you open voice mode."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Chats that keep their answers",
|
||||
"bullets": [
|
||||
"An answer is no longer lost when the connection drops mid-reply on a long turn (slow local models, delegating skills) — the app quietly re-checks the conversation and completes the turn when the server finishes, with the usual done-notification if you've switched away.",
|
||||
"Markdown reads like chat: headings are proportionate instead of billboard-sized, lists and paragraphs share one size, links are clearly styled, and timestamps show once per message group."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Your agent can reach out",
|
||||
"bullets": [
|
||||
"Proactive messages: your Hermes agent can message your phone first (off by default, opt-in on both server and phone), and you can reply straight from the notification or the new Hermes inbox — the conversation continues like any other chat."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Make it yours",
|
||||
"bullets": [
|
||||
"Pick your app font — Inter (new default), Nunito, or your system font — applied instantly, everywhere.",
|
||||
"The in-bubble working indicator can be a small animated dot-matrix (Wave, Pulse, Bounce, Sparkle) with a color of your choice.",
|
||||
"Quick Controls at the top of Settings puts Persistent connection and Turn-complete alerts one tap away."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup & housekeeping",
|
||||
"bullets": [
|
||||
"Onboarding slides now scroll on small screens and large font sizes, so no setup guidance is cut off.",
|
||||
"Reporting a diagnostic files the right kind of issue: informational entries ask what you expected and file as a question, and every report carries your actual connection mode.",
|
||||
"Connections is a scannable list with a tabbed detail screen (Overview, Routes, Advanced, Security), and voice settings can now read and edit your server's voice engine (provider, voice, model) over the dashboard."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 8px 6px 0 8px;
|
||||
/* Bottom gap so xterm's last row clears the extra-keys footer
|
||||
instead of butting flush against it (read as an overlap). */
|
||||
padding: 8px 6px 8px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
@@ -258,6 +260,36 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Mode-aware encoder for the on-screen toolbar's special keys
|
||||
// (arrows / Home / End / Page). Arrows must follow xterm's current
|
||||
// DECCKM (application cursor keys) mode: when an app like vim, less,
|
||||
// or readline has requested it, an arrow is SS3-encoded (\eOA) rather
|
||||
// than CSI (\e[A). The old path always sent CSI from Kotlin, which the
|
||||
// running TUI could misread. We read term.modes here (where the mode
|
||||
// actually lives) and route bytes back through onInput so sticky
|
||||
// modifiers still apply. Page keys are mode-independent.
|
||||
window.termSendKey = function (name) {
|
||||
var appCursor = false;
|
||||
try {
|
||||
appCursor = !!(term.modes && term.modes.applicationCursorKeysMode);
|
||||
} catch (_) {}
|
||||
var p = appCursor ? 'O' : '[';
|
||||
var map = {
|
||||
ArrowUp: p + 'A',
|
||||
ArrowDown: p + 'B',
|
||||
ArrowRight: p + 'C',
|
||||
ArrowLeft: p + 'D',
|
||||
Home: p + 'H',
|
||||
End: p + 'F',
|
||||
PageUp: '[5~',
|
||||
PageDown: '[6~',
|
||||
};
|
||||
var seq = map[name];
|
||||
if (seq && window.AndroidBridge && window.AndroidBridge.onInput) {
|
||||
window.AndroidBridge.onInput(seq);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Scroll shims + gesture ────────────────────────────────────────
|
||||
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
|
||||
// has no built-in mobile touch-to-scroll — its input handlers are
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
v1.0.0 - The 1.0 release
|
||||
v1.5.0 - Hermes, always in reach
|
||||
|
||||
Standard path
|
||||
* Chat, Manage, and voice now work on a plain Hermes agent — no relay
|
||||
plugin required. The plugin is optional and only adds power tools.
|
||||
|
||||
Chat
|
||||
* New gateway transport streams the agent's reasoning live, so the
|
||||
Thinking block fills in during generation instead of after.
|
||||
* Warm-start + opt-in "Keep connected in background" make returning to a
|
||||
conversation fast.
|
||||
* Attachments at desktop parity: images, PDFs, and files upload over the
|
||||
gateway. If a connection can't carry a file, you'll see a notice
|
||||
instead of a silent drop.
|
||||
* Steer a running turn, edit & resend your messages, watch subagent
|
||||
lanes, and a context-window meter — plus turn-complete notifications.
|
||||
* Tap an image to open it full-screen (pinch to zoom); save or share
|
||||
images and other attachments.
|
||||
* Redesigned input bar: pill field, one morphing Send/Voice/Stop button.
|
||||
|
||||
Profiles
|
||||
* Switch the whole agent — model, persona, and skills — per conversation.
|
||||
The drawer scopes to the active profile, and switching is ephemeral: it
|
||||
never changes your server's default agent.
|
||||
|
||||
Manage
|
||||
* Models, provider keys, profiles + SOUL.md, and a skills hub — parity
|
||||
with the desktop dashboard. Cached for instant cold-launch.
|
||||
|
||||
Voice
|
||||
* Realtime Agent keeps one session across turns; long runs continue in
|
||||
the background and are spoken when ready.
|
||||
|
||||
Polish
|
||||
* Seamless LAN/Tailscale handoffs (no chat reload), slide-down status
|
||||
toasts, and a broad round of fixes.
|
||||
* Connect through secure Dashboard sign-in and switch profiles from the new Agent Passport.
|
||||
* Keep multiple background chats active and reopen the right session from approval or question alerts.
|
||||
* Follow richer attachments, image generation, model routing, Gateway recovery, and streaming voice.
|
||||
* Finish setup with clear, optional permission guidance that never blocks standard chat.
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@@ -28,6 +29,9 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
// Install the crash handler FIRST so any failure in the rest of app
|
||||
// init (or anywhere later) is captured and surfaced on next launch.
|
||||
CrashReporter.install(this)
|
||||
AppAnalytics.initialize(this)
|
||||
// A8 — wire the bridge-gesture wake-lock wrapper so
|
||||
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
|
||||
|
||||
@@ -8,23 +8,24 @@ import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
|
||||
@@ -142,6 +143,10 @@ class MainActivity : ComponentActivity() {
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -114,8 +115,27 @@ class ScreenCapture(
|
||||
*/
|
||||
private const val MAX_IMAGES = 2
|
||||
|
||||
/** Capture timeout — if no frame arrives in this window, fail loudly. */
|
||||
private const val CAPTURE_TIMEOUT_MS = 2_500L
|
||||
/**
|
||||
* Capture timeout — if no frame arrives in this window, fail loudly.
|
||||
*
|
||||
* BOOX / e-ink devices can take several seconds before a
|
||||
* VirtualDisplay-backed ImageReader emits its first frame, especially
|
||||
* after a fresh MediaProjection grant or when the display is idle. Keep
|
||||
* the default generous enough for those devices while still bounded so
|
||||
* a dead capture pipeline reports a clear error.
|
||||
*/
|
||||
private const val DEFAULT_CAPTURE_TIMEOUT_MS = 10_000L
|
||||
|
||||
/** Optional JVM/system-property override for local QA and OEM tuning. */
|
||||
private const val CAPTURE_TIMEOUT_PROPERTY =
|
||||
"hermes.relay.screen_capture_timeout_ms"
|
||||
|
||||
private const val MIN_CAPTURE_TIMEOUT_MS = 2_500L
|
||||
private const val MAX_CAPTURE_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** One retry covers stale VirtualDisplay/ImageReader pipelines. */
|
||||
private const val MAX_CAPTURE_ATTEMPTS = 2
|
||||
private const val CAPTURE_RETRY_DELAY_MS = 350L
|
||||
}
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection reuse fix ===
|
||||
@@ -211,7 +231,27 @@ class ScreenCapture(
|
||||
// mutex keeps us honest if anything ever parallelizes.
|
||||
val pngBytes = try {
|
||||
captureMutex.withLock {
|
||||
captureFrame(projection)
|
||||
var lastTimeout: CaptureTimeoutException? = null
|
||||
for (attempt in 1..MAX_CAPTURE_ATTEMPTS) {
|
||||
try {
|
||||
return@withLock captureFrame(projection)
|
||||
} catch (e: CaptureTimeoutException) {
|
||||
lastTimeout = e
|
||||
Log.w(
|
||||
TAG,
|
||||
"screen capture timed out on attempt " +
|
||||
"$attempt/$MAX_CAPTURE_ATTEMPTS: ${e.message}"
|
||||
)
|
||||
if (attempt < MAX_CAPTURE_ATTEMPTS) {
|
||||
// A timeout can leave an OEM VirtualDisplay path
|
||||
// wedged without invalidating the MediaProjection
|
||||
// grant. Rebuild our pipeline once before giving up.
|
||||
releaseCache()
|
||||
delay(CAPTURE_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastTimeout ?: IOException("screen capture timed out")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "captureFrame failed: ${e.message}")
|
||||
@@ -286,16 +326,28 @@ class ScreenCapture(
|
||||
}
|
||||
|
||||
return try {
|
||||
kotlinx.coroutines.withTimeout(CAPTURE_TIMEOUT_MS) { deferred.await() }
|
||||
val timeoutMs = captureTimeoutMs()
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw IOException("screen capture timed out")
|
||||
throw CaptureTimeoutException(
|
||||
"screen capture timed out after ${captureTimeoutMs()}ms"
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureTimeoutMs(): Long {
|
||||
val configured = System.getProperty(CAPTURE_TIMEOUT_PROPERTY)
|
||||
?.toLongOrNull()
|
||||
?.coerceIn(MIN_CAPTURE_TIMEOUT_MS, MAX_CAPTURE_TIMEOUT_MS)
|
||||
return configured ?: DEFAULT_CAPTURE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private class CaptureTimeoutException(message: String) : IOException(message)
|
||||
|
||||
/**
|
||||
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
* for this projection. Rebuilds when:
|
||||
@@ -489,7 +541,7 @@ class ScreenCapture(
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val raw = response.body?.string().orEmpty()
|
||||
val raw = response.body.string()
|
||||
val token = extractToken(raw)
|
||||
if (token.isNullOrBlank()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@@ -25,7 +26,7 @@ import kotlin.math.sqrt
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
class RealtimePcmPlayer(private val context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
@@ -225,7 +226,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeAt(0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +241,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
playbackAmpQueue.removeAt(0)
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
@@ -449,7 +450,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
title = context?.getString(R.string.audio_diag_started) ?: "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
@@ -489,7 +490,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
title = context?.getString(R.string.audio_diag_not_starting) ?: "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
@@ -587,7 +588,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
title = context?.getString(R.string.audio_diag_stream_gap) ?: "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
@@ -603,7 +604,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
title = context?.getString(R.string.audio_diag_volume_muted) ?: "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.media.audiofx.Visualizer
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -38,7 +40,13 @@ import kotlin.math.sqrt
|
||||
* The Visualizer is attached exactly once against the ExoPlayer's
|
||||
* [ExoPlayer.getAudioSessionId]. There is a known gotcha where re-attaching
|
||||
* the Visualizer on every track transition invalidates the session id — the
|
||||
* single-attach lifecycle here sidesteps it entirely.
|
||||
* single-attach lifecycle here sidesteps it entirely. The single attach is
|
||||
* triggered by whichever of {playback became live, a real session id landed}
|
||||
* arrives last, so a late AudioTrack allocation (deep-buffer cold-start) can't
|
||||
* leave amplitude pinned at 0 for the turn — see [attachVisualizerIfPlaying].
|
||||
* That promptness matters because the voice overlay gates its output waveform
|
||||
* on the first real playback-amplitude frame, so the visual follows audible
|
||||
* speech instead of leading it.
|
||||
*
|
||||
* @param context used for [ExoPlayer.Builder]. Application context is fine;
|
||||
* the player holds no view references.
|
||||
@@ -109,6 +117,21 @@ class VoicePlayer(
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
// Deep-buffer cold-start guard. On some OEM pipelines the
|
||||
// AudioTrack — and therefore a real (non-zero) session id —
|
||||
// isn't allocated until *after* onIsPlayingChanged(true) has
|
||||
// already fired. In that race the isPlaying-driven attach
|
||||
// below ran with id == 0, no-oped, and isPlaying will not
|
||||
// toggle again for the rest of a continuous TTS turn, so the
|
||||
// Visualizer would never attach and [amplitude] would stay
|
||||
// pinned at 0 for the whole turn. The output waveform gates
|
||||
// its unfold on the first real playback-amplitude frame, so a
|
||||
// never-firing amplitude leaves it stuck in the folded
|
||||
// processing/spinner shape even though audio is audible.
|
||||
// Attaching here — the moment a real session id lands while
|
||||
// playback is already live — makes the first-audible-frame
|
||||
// signal reliable regardless of when the track allocates.
|
||||
attachVisualizerIfPlaying()
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
@@ -124,11 +147,11 @@ class VoicePlayer(
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader).
|
||||
// starts its IO reader). If the id isn't ready yet, the
|
||||
// analytics callback above re-tries the attach the instant
|
||||
// it lands (see attachVisualizerIfPlaying).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
if (!visualizerAttached) {
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
attachVisualizerIfPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,6 +331,24 @@ class VoicePlayer(
|
||||
exoPlayer.release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the [Visualizer] iff playback is live and we haven't attached for
|
||||
* this session yet. Idempotent and main-thread-only: both call sites
|
||||
* ([Player.Listener.onIsPlayingChanged] and the [AnalyticsListener]'s
|
||||
* `onAudioSessionIdChanged`) are delivered on the player's application
|
||||
* thread, so the [visualizerAttached] check needs no extra synchronization.
|
||||
*
|
||||
* The delegate [attachVisualizer] still no-ops (without latching
|
||||
* [visualizerAttached]) when the cached session id is 0, which preserves
|
||||
* the retry: whichever of {isPlaying, valid session id} arrives last drives
|
||||
* the single attach. This is the cold-start race fix — see the
|
||||
* `onAudioSessionIdChanged` comment in `init`.
|
||||
*/
|
||||
private fun attachVisualizerIfPlaying() {
|
||||
if (visualizerAttached || !_isPlaying.value) return
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
|
||||
private fun attachVisualizer(audioSessionId: Int) {
|
||||
if (audioSessionId == 0) {
|
||||
// ExoPlayer returns 0 before the audio track is allocated; retry
|
||||
@@ -386,9 +427,25 @@ class VoicePlayer(
|
||||
* Production ExoPlayer factory — used as the default for [VoicePlayer].
|
||||
* Split out as a top-level function so unit tests can swap it for a
|
||||
* MockK mock without touching Media3's `Builder` class loader.
|
||||
*
|
||||
* Audio attributes (USAGE_MEDIA + CONTENT_TYPE_SPEECH) with
|
||||
* `handleAudioFocus = true` are set so ExoPlayer requests audio focus when
|
||||
* the first TTS clip starts, which warms the audio HAL output path before
|
||||
* playback begins. Without them the very first turn of a cold voice session
|
||||
* could lose its opening syllables to the AudioTrack/HAL allocation window —
|
||||
* the standard-path twin of the deep-buffer cold-start the relay PCM player
|
||||
* already mitigates. SPEECH also lets the system duck other audio
|
||||
* appropriately for a spoken assistant reply.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun defaultExoPlayer(context: Context): ExoPlayer =
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,6 +57,8 @@ class VoiceRecorder(
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var echoCanceler: AcousticEchoCanceler? = null
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
@@ -117,6 +121,7 @@ class VoiceRecorder(
|
||||
throw e
|
||||
}
|
||||
|
||||
attachVoiceEffects(recorder.audioSessionId)
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
@@ -136,6 +141,9 @@ class VoiceRecorder(
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
// Claim the capture exactly once. A stale UI stop must not repackage
|
||||
// the previous PCM as a second voice turn.
|
||||
currentOutputFile = null
|
||||
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
@@ -202,8 +210,15 @@ class VoiceRecorder(
|
||||
}
|
||||
}
|
||||
updateAmplitude(buffer, read)
|
||||
} else if (read < 0) {
|
||||
Log.w(TAG, "AudioRecord.read ended with error code $read")
|
||||
break
|
||||
}
|
||||
}
|
||||
// Android can terminate capture while the app is backgrounded without
|
||||
// stopRecording() running. Reflect that loss in isRecording() so the
|
||||
// foreground UI can recover instead of remaining stuck on Listening.
|
||||
stopRequested.set(true)
|
||||
}
|
||||
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
@@ -224,7 +239,44 @@ class VoiceRecorder(
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engage the platform's hardware echo-cancellation and noise-suppression
|
||||
* on the [AudioRecord] capture session when the device exposes them —
|
||||
* parity with hermes-desktop's `getUserMedia({echoCancellation,
|
||||
* noiseSuppression})`. Both are best-effort: many mid-range and older
|
||||
* devices report [AcousticEchoCanceler.isAvailable] / [NoiseSuppressor.isAvailable]
|
||||
* false, in which case capture proceeds raw (the same behaviour as before
|
||||
* this change). AEC in particular keeps the device's own TTS playback from
|
||||
* bleeding into the next captured utterance during back-to-back voice turns.
|
||||
*/
|
||||
private fun attachVoiceEffects(sessionId: Int) {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
echoCanceler = try {
|
||||
AcousticEchoCanceler.create(sessionId)?.apply { enabled = true }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AcousticEchoCanceler unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
noiseSuppressor = try {
|
||||
NoiseSuppressor.create(sessionId)?.apply { enabled = true }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "NoiseSuppressor unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
echoCanceler?.let { fx ->
|
||||
try { fx.release() } catch (_: Exception) { }
|
||||
}
|
||||
echoCanceler = null
|
||||
noiseSuppressor?.let { fx ->
|
||||
try { fx.release() } catch (_: Exception) { }
|
||||
}
|
||||
noiseSuppressor = null
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -92,6 +93,19 @@ class AuthManager(
|
||||
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
|
||||
*/
|
||||
private val tokenStoreKey: String? = null,
|
||||
/**
|
||||
* When false, [init] skips the eager session-token hydration (and the
|
||||
* keyset decrypt it forces). Used for the throwaway LEGACY SENTINEL manager
|
||||
* that `ConnectionViewModel` builds at field-init and replaces as soon as
|
||||
* the active connection hydrates — decrypting its keyset only to discard it
|
||||
* is a measured ~600 ms of wasted startup keystore work, and on a device
|
||||
* whose active connection isn't connection 0 the sentinel's file has no
|
||||
* token anyway. The real per-connection manager (created via the active
|
||||
* connection, [eagerHydrate] = true) hydrates normally; the
|
||||
* `restorePersistedActiveConnectionContext` path even awaits its
|
||||
* Paired/Failed state. Channel handlers are still registered either way.
|
||||
*/
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
companion object {
|
||||
@@ -102,6 +116,10 @@ class AuthManager(
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
// Marker (in the connection-0 token store) recording that the one-shot
|
||||
// pre-StrongBox `hermes_companion_auth` → `hermes_companion_auth_hw`
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
@@ -329,31 +347,29 @@ class AuthManager(
|
||||
_store?.let { return it }
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: [tokenPrefsName] picks the
|
||||
// EncryptedSharedPreferences filename for the bound
|
||||
// connection. The legacy sentinel keeps the pre-multi-
|
||||
// connection install on its original file so the existing
|
||||
// paired device keeps working with no migration.
|
||||
// Both encrypted backends decrypt their Tink keyset eagerly on
|
||||
// construction, so a corrupt file can throw AEADBadTagException
|
||||
// here. KeystoreTokenStore.tryCreate already degrades to null;
|
||||
// the legacy store self-heals its file in its constructor. If
|
||||
// even that rebuild fails (a fundamentally broken keystore),
|
||||
// fall back to a non-persistent store rather than force-close —
|
||||
// the user re-pairs, but the app stays up.
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, tokenPrefsName)
|
||||
?: runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(context, tokenPrefsName)
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Legacy token store unavailable (${e.message}) — using in-memory fallback; re-pair required")
|
||||
InMemoryTokenStore()
|
||||
}
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
val picked = withContext(Dispatchers.IO) {
|
||||
// One keyset build per file, process-wide (see [SecureStoreCache]).
|
||||
// The legacy sentinel is deferred (eagerHydrate=false) and the
|
||||
// dashboard cookie store now shares this same file, so the active
|
||||
// connection's token keyset is the ONLY one built on the cold-
|
||||
// start critical path. [tokenPrefsName] picks the file.
|
||||
//
|
||||
// The build decrypts its Tink keyset eagerly, so a corrupt file
|
||||
// can throw AEADBadTagException — KeystoreTokenStore.tryCreate
|
||||
// degrades to null, the legacy store self-heals in its ctor, and
|
||||
// a fundamentally broken keystore falls back to InMemory (the app
|
||||
// stays up; the user re-pairs). See [buildRawTokenStore].
|
||||
val s = SecureStoreCache.getOrBuild(tokenPrefsName) {
|
||||
buildRawTokenStore(context, tokenPrefsName)
|
||||
}
|
||||
// Migration runs AFTER the (shared) build so the cookie store can
|
||||
// trigger the build without needing token-migration logic; a
|
||||
// marker makes it read the legacy file at most once ever.
|
||||
migrateFromLegacyIfNeeded(s)
|
||||
s
|
||||
}
|
||||
_store = picked
|
||||
picked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,14 +381,33 @@ class AuthManager(
|
||||
*/
|
||||
private fun migrateFromLegacyIfNeeded(picked: SessionTokenStore) {
|
||||
if (picked is LegacyEncryptedPrefsTokenStore) return
|
||||
// Multi-connection: only the legacy connection inherits from the pre-
|
||||
// multi-connection `hermes_companion_auth` file. A freshly-minted
|
||||
// per-connection store must NOT be seeded from the legacy file or
|
||||
// Gate on the FILE, not the connection id. Only the legacy connection-0
|
||||
// file (`hermes_companion_auth_hw`) inherits from the pre-multi-
|
||||
// connection `hermes_companion_auth` file; a freshly-minted per-
|
||||
// connection store (`hermes_auth_<id>`) must NOT be seeded from it or
|
||||
// we'd copy connection 0's token into every new connection.
|
||||
if (connectionId != CONNECTION_ID_LEGACY) return
|
||||
//
|
||||
// Why file-gated rather than `connectionId == CONNECTION_ID_LEGACY`:
|
||||
// the store build is now cached/deduped across the legacy sentinel and
|
||||
// the real connection-0 manager, so whichever one builds the file first
|
||||
// runs this migration. Both share `tokenPrefsName == LEGACY_TOKEN_STORE_KEY`
|
||||
// but only the sentinel had `connectionId == CONNECTION_ID_LEGACY`, so
|
||||
// the old id-based gate would skip migration whenever the real manager
|
||||
// won the race — dropping a pre-StrongBox user's token. The file name is
|
||||
// the same for both, so gating on it is race-proof.
|
||||
if (tokenPrefsName != Connection.LEGACY_TOKEN_STORE_KEY) return
|
||||
// Read the legacy file at most ONCE ever. The build is now cache-shared
|
||||
// (and the cookie store can trigger it without migrating), so without
|
||||
// this marker every freshly-rebuilt connection-0 AuthManager would
|
||||
// re-build the legacy `hermes_companion_auth` keyset just to find it
|
||||
// already drained — re-introducing the startup cost we just removed.
|
||||
if (picked.contains(KEY_LEGACY_MIGRATED)) return
|
||||
val legacy = try {
|
||||
LegacyEncryptedPrefsTokenStore(context)
|
||||
} catch (_: Exception) {
|
||||
// Legacy file unreadable/corrupt — nothing to inherit. Still mark
|
||||
// done so its keyset isn't rebuilt on every launch.
|
||||
picked.putString(KEY_LEGACY_MIGRATED, "1")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,6 +431,7 @@ class AuthManager(
|
||||
// backup copies of the session token lying around.
|
||||
legacy.clearAll()
|
||||
}
|
||||
picked.putString(KEY_LEGACY_MIGRATED, "1")
|
||||
}
|
||||
|
||||
/** Cert pin store — shared across all relay connections. */
|
||||
@@ -524,24 +560,28 @@ class AuthManager(
|
||||
// one-line change in [onMessage].
|
||||
multiplexer.registerHandler("pairing", this)
|
||||
|
||||
// Check for existing session token off main thread
|
||||
scope.launch {
|
||||
val s = store()
|
||||
val existingToken = s.getString(KEY_SESSION_TOKEN)
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
// Check for existing session token off main thread. Skipped for the
|
||||
// throwaway sentinel (eagerHydrate=false) so it never pays the keyset
|
||||
// decrypt for a store that's about to be replaced (see [eagerHydrate]).
|
||||
if (eagerHydrate) {
|
||||
scope.launch {
|
||||
val s = store()
|
||||
val existingToken = s.getString(KEY_SESSION_TOKEN)
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
}
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
}
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +703,18 @@ class AuthManager(
|
||||
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability negotiation advertised in the first system/auth envelope.
|
||||
* Older relays ignore this object; newer relays use it to send versioned
|
||||
* `chat:stream.event` payloads instead of flattening Hermes SSE into text.
|
||||
*/
|
||||
private fun JsonObjectBuilder.putRelayClientSupports() {
|
||||
put("supports", buildJsonObject {
|
||||
put("typed_stream_events", true)
|
||||
put("event_schema_version", 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send auth envelope when connection is established.
|
||||
*
|
||||
@@ -698,6 +750,7 @@ class AuthManager(
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -713,6 +766,7 @@ class AuthManager(
|
||||
put("pairing_code", codeToSend)
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
putRelayClientSupports()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
@@ -844,6 +898,18 @@ class AuthManager(
|
||||
val profilesUpdatedEvents: kotlinx.coroutines.flow.SharedFlow<Unit> =
|
||||
_profilesUpdatedEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits once per successful `auth.ok` — i.e. on every (re)connect, not
|
||||
* just the first pair. Lets connection-scoped consumers re-establish
|
||||
* per-socket state. The proactive subscription is tracked per-WebSocket
|
||||
* on the relay, so [com.hermesandroid.relay.viewmodel.ConnectionViewModel]
|
||||
* collects this to re-send `proactive.subscribe` after each reconnect.
|
||||
*/
|
||||
private val _authOkEvents =
|
||||
kotlinx.coroutines.flow.MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
val authOkEvents: kotlinx.coroutines.flow.SharedFlow<Unit> =
|
||||
_authOkEvents.asSharedFlow()
|
||||
|
||||
fun regeneratePairingCode() {
|
||||
_pairingCode.value = generatePairingCode()
|
||||
}
|
||||
@@ -911,6 +977,9 @@ class AuthManager(
|
||||
}
|
||||
_authState.value = AuthState.Paired(token)
|
||||
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
|
||||
// Per-connection signal for socket-scoped consumers (e.g.
|
||||
// re-sending proactive.subscribe). Fires on every auth.ok.
|
||||
_authOkEvents.tryEmit(Unit)
|
||||
// Server-issued code is one-shot — drop it once the
|
||||
// upgrade to a long-lived session token has landed.
|
||||
serverIssuedCode = null
|
||||
|
||||
@@ -6,6 +6,44 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Process-global cache for encrypted stores, keyed by prefs-file name.
|
||||
*
|
||||
* `EncryptedSharedPreferences.create()` unwraps a Tink keyset via a KeyStore op
|
||||
* (~0.6–1 s on StrongBox), and Tink serializes those process-globally — so a
|
||||
* second build of the SAME file is pure waste (the measured cold-start
|
||||
* `Long monitor contention … AndroidKeysetManager.build()` with `waiters=1..4`).
|
||||
*
|
||||
* Caching by file name means each file's keyset builds ONCE process-wide. The
|
||||
* cache is **synchronous** ([ConcurrentHashMap.computeIfAbsent], which holds a
|
||||
* per-key lock so the build runs at most once per file) precisely so the SAME
|
||||
* instance serves both the suspend token path (callers wrap this in
|
||||
* [kotlinx.coroutines.Dispatchers.IO]) AND the synchronous OkHttp cookie-jar
|
||||
* path — which is how the dashboard cookies now ride the connection's
|
||||
* already-built token keyset instead of building a second one.
|
||||
*
|
||||
* The build is ~1 s on StrongBox: call only from IO / OkHttp threads, never the
|
||||
* main thread.
|
||||
*/
|
||||
internal object SecureStoreCache {
|
||||
private val instances = ConcurrentHashMap<String, SessionTokenStore>()
|
||||
|
||||
fun getOrBuild(prefsName: String, build: () -> SessionTokenStore): SessionTokenStore =
|
||||
instances.computeIfAbsent(prefsName) { build() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the raw encrypted store for [prefsName] — Keystore-backed when possible,
|
||||
* self-healing legacy fallback, in-memory last resort. No migration. Shared by
|
||||
* the token store and the dashboard cookie store so a given file always yields
|
||||
* the SAME backend, via [SecureStoreCache].
|
||||
*/
|
||||
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
|
||||
.getOrElse { InMemoryTokenStore() }
|
||||
|
||||
/**
|
||||
* Abstraction over the storage backend for the relay session token + API key
|
||||
|
||||
@@ -89,8 +89,8 @@ class AutoDisableWorker(private val context: Context) {
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Bridge auto-disabled")
|
||||
.setContentText("Paused after idle — tap to re-enable in the Bridge tab.")
|
||||
.setContentTitle(context.getString(R.string.bridge_notification_auto_disabled_title))
|
||||
.setContentText(context.getString(R.string.bridge_notification_auto_disabled_body))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"Hermes bridge was idle for too long, so device control has been turned off " +
|
||||
"automatically. Open the Bridge tab to turn it back on if you still need it."
|
||||
|
||||
@@ -373,8 +373,8 @@ class BridgeForegroundService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes agent has device control")
|
||||
.setContentText("Bridge is active — tap Disable to stop at any time.")
|
||||
.setContentTitle(getString(R.string.bridge_notification_control_title))
|
||||
.setContentText(getString(R.string.bridge_notification_control_body))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(
|
||||
"The Hermes agent can currently read the screen and perform " +
|
||||
"actions on your behalf through the accessibility service. " +
|
||||
|
||||
@@ -31,7 +31,16 @@ object AgentDisplay {
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
serverDefaultProfileName: String? = null,
|
||||
): Profile? {
|
||||
selectedProfile?.let { return it }
|
||||
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
|
||||
return resolvedServerDefault
|
||||
?.let { activeName ->
|
||||
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
|
||||
}
|
||||
?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
}
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
@@ -137,6 +146,24 @@ object AgentDisplay {
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
|
||||
|
||||
/**
|
||||
* The profile name that owns chat sessions for the current UI selection.
|
||||
*
|
||||
* [selectedProfileName] is null (or the synthetic `default` alias) for the
|
||||
* "Server default" row. That UI sentinel must remain distinct from the
|
||||
* server's sticky active profile: a dashboard launched under the root home
|
||||
* may still report `active=victor`, in which case upstream Gateway and
|
||||
* dashboard session calls must explicitly target `victor`. The resolved
|
||||
* server value deliberately keeps the literal `default` name so a dashboard
|
||||
* launched under another profile can still address the root profile.
|
||||
*/
|
||||
fun effectiveSessionProfileName(
|
||||
selectedProfileName: String?,
|
||||
serverDefaultProfileName: String?,
|
||||
): String? =
|
||||
profileRequestName(selectedProfileName)
|
||||
?: serverDefaultProfileName?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun profileSessionKey(profileName: String?): String =
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/** Languages exposed by the in-app picker and Android's per-app language UI. */
|
||||
enum class AppLanguage(val languageTag: String) {
|
||||
SYSTEM_DEFAULT(""),
|
||||
ENGLISH("en"),
|
||||
GERMAN("de"),
|
||||
BRAZILIAN_PORTUGUESE("pt-BR"),
|
||||
JAPANESE("ja"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
SPANISH("es"),
|
||||
;
|
||||
|
||||
fun toLocaleList(): LocaleListCompat = if (languageTag.isEmpty()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(languageTag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromLanguageTags(languageTags: String): AppLanguage {
|
||||
val primaryTag = languageTags
|
||||
.substringBefore(',')
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: return SYSTEM_DEFAULT
|
||||
val locale = Locale.forLanguageTag(primaryTag)
|
||||
|
||||
return when (locale.language.lowercase(Locale.ROOT)) {
|
||||
"de" -> GERMAN
|
||||
"en" -> ENGLISH
|
||||
"es" -> SPANISH
|
||||
"ja" -> JAPANESE
|
||||
"pt" -> BRAZILIAN_PORTUGUESE
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
locale.country.equals("CN", ignoreCase = true) ||
|
||||
locale.country.equals("SG", ignoreCase = true)
|
||||
if (simplified) SIMPLIFIED_CHINESE else SYSTEM_DEFAULT
|
||||
}
|
||||
else -> SYSTEM_DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ class BargeInPreferencesRepository(
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
private val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
private val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
internal val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
internal val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
internal val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
|
||||
@@ -89,9 +89,101 @@ data class ChatMessage(
|
||||
* the durable session turn; the provider's spoken summary is UI/runtime
|
||||
* provenance, not another canonical assistant message.
|
||||
*/
|
||||
val realtimeTurn: RealtimeTurnTrace? = null
|
||||
val realtimeTurn: RealtimeTurnTrace? = null,
|
||||
/**
|
||||
* True for bubbles that exist ONLY on the client and have no server-side
|
||||
* row — slash-command notices, voice-intent traces, the steer echo, gateway
|
||||
* ask cards, an errored turn the server never persisted, and a provider-only
|
||||
* (non-Hermes-backed) realtime turn. The post-turn history reload
|
||||
* ([com.hermesandroid.relay.network.upstream.ChatHandler.loadMessageHistory])
|
||||
* preserves any client-only message whose id is absent from the reloaded
|
||||
* server transcript; without the flag those orphans would be silently
|
||||
* wiped by the reconcile.
|
||||
*
|
||||
* Replaces the old id-prefix whitelist (`voice-intent-`/`steer-`/`ask-`/
|
||||
* `system-notice-`) + "Error"-badge sniffing: each creator now declares its
|
||||
* own provenance instead of the reconcile having to know every id
|
||||
* convention. Defaults false so every server-backed message and existing
|
||||
* call site stays correct.
|
||||
*
|
||||
* NOTE: an "Error" badge alone does NOT make a message preservable — a turn
|
||||
* can error *after* persisting server-side, and that message must still
|
||||
* reconcile normally. Only [clientOnly] gates orphan preservation.
|
||||
*/
|
||||
val clientOnly: Boolean = false,
|
||||
/**
|
||||
* Delivery state for a message the user sends into an agent **Thread** over
|
||||
* the relay proactive channel ([com.hermesandroid.relay.viewmodel.ChatViewModel]
|
||||
* routes `source=phone` sessions here instead of the normal chat send).
|
||||
* `SENDING` until the relay acks (`proactive.reply.ack`) → `DELIVERED`;
|
||||
* `FAILED` on a send error. Null for ordinary chat messages — those render
|
||||
* no status affix.
|
||||
*/
|
||||
val deliveryStatus: MessageDeliveryStatus? = null,
|
||||
/**
|
||||
* Client-side lifecycle for a promoted/durable Hermes run that belongs to
|
||||
* this assistant turn. The same message owns the state from promotion
|
||||
* through delivery so Chat never needs a separate system notice and final
|
||||
* reply for one task. On the normal post-turn history reconcile this field
|
||||
* is carried forward with the rest of the client-only enrichment whenever
|
||||
* the live message can be matched to its server row.
|
||||
*/
|
||||
val backgroundTask: BackgroundTaskState? = null,
|
||||
/**
|
||||
* Stable identity for Compose list rendering.
|
||||
*
|
||||
* Gateway/user rows start with client UUIDs, then post-turn history
|
||||
* reconciliation adopts the server message id into [id]. That server-id
|
||||
* adoption must not make a visible bubble look removed and reinserted to
|
||||
* LazyColumn: doing so discards its scroll anchor, which is especially
|
||||
* disruptive when the row is a long answer occupying the viewport.
|
||||
*
|
||||
* New rows default to their current [id]. Reconciled rows retain this key
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
data class BackgroundTaskState(
|
||||
/** Relay run id when supplied; otherwise a stable id derived from the message. */
|
||||
val id: String,
|
||||
/** Short objective derived from the associated user turn. */
|
||||
val title: String,
|
||||
/** ADR 33 tier: `promoted` or `durable`. */
|
||||
val tier: String = "promoted",
|
||||
val phase: BackgroundTaskPhase = BackgroundTaskPhase.RUNNING,
|
||||
/** Latest meaningful progress line, deliberately not a raw event trace. */
|
||||
val statusLine: String? = null,
|
||||
val completedToolCount: Int = 0,
|
||||
val queuedCount: Int = 0,
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
enum class BackgroundTaskPhase {
|
||||
RUNNING,
|
||||
WAITING,
|
||||
DELIVERING,
|
||||
COMPLETE,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured details about a phone-local voice intent that was dispatched
|
||||
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
|
||||
@@ -186,7 +278,23 @@ data class Attachment(
|
||||
/** Opaque token from `MEDIA:hermes-relay://<token>` — identifies the file on the relay. */
|
||||
val relayToken: String? = null,
|
||||
/** content:// URI from the FileProvider once bytes are cached to disk. */
|
||||
val cachedUri: String? = null
|
||||
val cachedUri: String? = null,
|
||||
/**
|
||||
* Whether this attachment was flagged sensitive (NSFW / spoiler) and should
|
||||
* render blurred until the user taps to reveal — honored per the user's
|
||||
* `MediaSettings.blurMode`.
|
||||
*
|
||||
* The flag is **model-emitted metadata, never an on-device or relay-side
|
||||
* classifier** (see `docs/plans/2026-06-18-attachment-experience.md` §C): the
|
||||
* agent annotates media it surfaces, the relay transports the bit
|
||||
* authoritatively via the `X-Media-Sensitive` response header, and the
|
||||
* client merely renders the blur. Populated for inbound attachments from
|
||||
* [com.hermesandroid.relay.network.relay.RelayHttpClient.FetchedMedia.sensitive]
|
||||
* when the bytes flip to [AttachmentState.LOADED]. Defaults false so every
|
||||
* existing outbound/inbound call site stays valid and unflagged media
|
||||
* renders exactly as before.
|
||||
*/
|
||||
val sensitive: Boolean = false
|
||||
) {
|
||||
val isImage: Boolean get() = contentType.startsWith("image/")
|
||||
|
||||
@@ -258,7 +366,13 @@ data class ToolCall(
|
||||
* goal truncated to 60 chars. Carried on each child call so the lane
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null
|
||||
val taskLabel: String? = null,
|
||||
/** Deterministic non-low output risk reported by upstream for this call. */
|
||||
val outputRisk: String? = null,
|
||||
/** Human-readable deterministic findings; rendered as untrusted metadata. */
|
||||
val outputRiskFindings: List<String> = emptyList(),
|
||||
/** Upstream removed sensitive spans before emitting the findings. */
|
||||
val outputRiskRedacted: Boolean = false,
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
@@ -267,6 +381,17 @@ enum class MessageRole {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery state of a user reply sent into an agent Thread over the relay
|
||||
* proactive channel. Only set on Thread replies; ordinary chat messages leave
|
||||
* it null and show no status affix.
|
||||
*
|
||||
* - [SENDING] handed to the relay; awaiting the per-reply ack.
|
||||
* - [DELIVERED] the relay acked (`proactive.reply.ack`) — buffered for the agent.
|
||||
* - [FAILED] the send errored (e.g. relay disconnected).
|
||||
*/
|
||||
enum class MessageDeliveryStatus { SENDING, DELIVERED, FAILED }
|
||||
|
||||
data class ChatSession(
|
||||
val sessionId: String,
|
||||
val title: String?,
|
||||
@@ -274,7 +399,16 @@ data class ChatSession(
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L
|
||||
val lastActivityAt: Long = 0L,
|
||||
/**
|
||||
* Originating gateway platform/source for this session (upstream `sessions.source`):
|
||||
* `tui`/`api_server` for ordinary app chats, `phone` for an agent **Thread**, and
|
||||
* `discord`/`slack`/… for other platforms. Null when the server didn't supply it or
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Durable, client-owned snapshot of one in-flight chat turn.
|
||||
*
|
||||
* Hermes history is authoritative once a turn finishes, but it cannot recreate
|
||||
* transient UI that existed before persistence (live reasoning, a running tool,
|
||||
* an interactive ask, or the latest lifecycle line). This checkpoint bridges
|
||||
* that gap across Activity recreation and process death. It deliberately stores
|
||||
* no entered secret/approval response; only the server-issued ask is retained.
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatTurnCheckpoint(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA,
|
||||
val contextKey: String,
|
||||
val sessionId: String,
|
||||
val liveSessionId: String? = null,
|
||||
val transport: String,
|
||||
val user: ChatTurnUserCheckpoint,
|
||||
val assistant: ChatTurnAssistantCheckpoint,
|
||||
val turnStatus: String? = null,
|
||||
val priorUserMessageCount: Int,
|
||||
val baselineAssistantCount: Int,
|
||||
val pendingAsk: ChatTurnAskCheckpoint? = null,
|
||||
val startedAt: Long,
|
||||
val updatedAt: Long,
|
||||
) {
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA = 1
|
||||
const val MAX_AGE_MS = 24L * 60L * 60L * 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnUserCheckpoint(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnAssistantCheckpoint(
|
||||
val id: String,
|
||||
val content: String = "",
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = true,
|
||||
val thinkingContent: String = "",
|
||||
val isThinkingStreaming: Boolean = false,
|
||||
val inputTokens: Int? = null,
|
||||
val outputTokens: Int? = null,
|
||||
val totalTokens: Int? = null,
|
||||
val estimatedCost: Double? = null,
|
||||
val agentName: String? = null,
|
||||
val badges: List<String> = emptyList(),
|
||||
val cards: List<HermesCard> = emptyList(),
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnToolCheckpoint(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val result: String? = null,
|
||||
val success: Boolean? = null,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
val startedAt: Long,
|
||||
val completedAt: Long? = null,
|
||||
val isGenerating: Boolean = false,
|
||||
val taskIndex: Int? = null,
|
||||
val taskLabel: String? = null,
|
||||
val outputRisk: String? = null,
|
||||
val outputRiskFindings: List<String> = emptyList(),
|
||||
val outputRiskRedacted: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnBackgroundTaskCheckpoint(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val tier: String,
|
||||
val phase: String,
|
||||
val statusLine: String? = null,
|
||||
val completedToolCount: Int = 0,
|
||||
val queuedCount: Int = 0,
|
||||
val startedAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnAskCheckpoint(
|
||||
val kind: String,
|
||||
val requestId: String? = null,
|
||||
val text: String,
|
||||
val choices: List<String>? = null,
|
||||
val smartDenied: Boolean = false,
|
||||
val envVar: String? = null,
|
||||
val timeoutSeconds: Int,
|
||||
val messageId: String,
|
||||
val cardKey: String,
|
||||
/** Original receive time, used to preserve an ask's expiry after reopen. */
|
||||
val receivedAt: Long,
|
||||
)
|
||||
|
||||
interface ChatTurnCheckpointStore {
|
||||
suspend fun read(): ChatTurnCheckpoint?
|
||||
suspend fun readAll(): List<ChatTurnCheckpoint> = listOfNotNull(read())
|
||||
suspend fun read(contextKey: String, sessionId: String): ChatTurnCheckpoint? =
|
||||
readAll()
|
||||
.filter { it.contextKey == contextKey && it.sessionId == sessionId }
|
||||
.maxByOrNull(ChatTurnCheckpoint::updatedAt)
|
||||
suspend fun write(checkpoint: ChatTurnCheckpoint)
|
||||
suspend fun remove(contextKey: String, sessionId: String) {
|
||||
if (read()?.let { it.contextKey == contextKey && it.sessionId == sessionId } == true) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
class DataStoreChatTurnCheckpointStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ChatTurnCheckpointStore {
|
||||
constructor(context: Context) : this(context.applicationContext.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun read(): ChatTurnCheckpoint? =
|
||||
readAll().maxByOrNull(ChatTurnCheckpoint::updatedAt)
|
||||
|
||||
override suspend fun readAll(): List<ChatTurnCheckpoint> {
|
||||
val preferences = runCatching { dataStore.data.first() }.getOrNull() ?: return emptyList()
|
||||
val decoded = decode(preferences)
|
||||
val valid = decoded.filter(::isValid)
|
||||
.distinctBy { it.contextKey to it.sessionId }
|
||||
if (valid.size != decoded.size ||
|
||||
(preferences[KEY_CHECKPOINT_SET] == null && preferences[KEY_CHECKPOINT] != null)
|
||||
) {
|
||||
// Cleanup/migration is best-effort. A read must still return the
|
||||
// valid subset if DataStore's atomic rewrite is briefly unavailable.
|
||||
runCatching { replaceAll(valid) }
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
override suspend fun read(contextKey: String, sessionId: String): ChatTurnCheckpoint? =
|
||||
readAll().firstOrNull { it.contextKey == contextKey && it.sessionId == sessionId }
|
||||
|
||||
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
|
||||
dataStore.edit { preferences ->
|
||||
val merged = mergeChatTurnCheckpoints(
|
||||
existing = decode(preferences),
|
||||
checkpoint = checkpoint,
|
||||
now = now(),
|
||||
limit = MAX_CHECKPOINTS,
|
||||
)
|
||||
preferences[KEY_CHECKPOINT_SET] = json.encodeToString(
|
||||
ChatTurnCheckpointSet(checkpoints = merged),
|
||||
)
|
||||
preferences.remove(KEY_CHECKPOINT)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(contextKey: String, sessionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val remaining = removeChatTurnCheckpoint(
|
||||
decode(preferences),
|
||||
contextKey,
|
||||
sessionId,
|
||||
)
|
||||
if (remaining.isEmpty()) {
|
||||
preferences.remove(KEY_CHECKPOINT_SET)
|
||||
} else {
|
||||
preferences[KEY_CHECKPOINT_SET] = json.encodeToString(
|
||||
ChatTurnCheckpointSet(checkpoints = remaining),
|
||||
)
|
||||
}
|
||||
preferences.remove(KEY_CHECKPOINT)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
dataStore.edit { preferences ->
|
||||
preferences.remove(KEY_CHECKPOINT)
|
||||
preferences.remove(KEY_CHECKPOINT_SET)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(preferences: Preferences): List<ChatTurnCheckpoint> {
|
||||
val current = preferences[KEY_CHECKPOINT_SET]?.let { raw ->
|
||||
runCatching { json.decodeFromString<ChatTurnCheckpointSet>(raw) }.getOrNull()
|
||||
}
|
||||
if (current?.schemaVersion == ChatTurnCheckpointSet.CURRENT_SCHEMA) {
|
||||
return current.checkpoints
|
||||
}
|
||||
return preferences[KEY_CHECKPOINT]?.let { raw ->
|
||||
listOfNotNull(runCatching { json.decodeFromString<ChatTurnCheckpoint>(raw) }.getOrNull())
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
private fun isValid(checkpoint: ChatTurnCheckpoint): Boolean =
|
||||
checkpoint.schemaVersion == ChatTurnCheckpoint.CURRENT_SCHEMA &&
|
||||
now() - checkpoint.updatedAt <= ChatTurnCheckpoint.MAX_AGE_MS
|
||||
|
||||
private suspend fun replaceAll(checkpoints: List<ChatTurnCheckpoint>) {
|
||||
dataStore.edit { preferences ->
|
||||
if (checkpoints.isEmpty()) {
|
||||
preferences.remove(KEY_CHECKPOINT_SET)
|
||||
} else {
|
||||
preferences[KEY_CHECKPOINT_SET] = json.encodeToString(
|
||||
ChatTurnCheckpointSet(checkpoints = checkpoints),
|
||||
)
|
||||
}
|
||||
preferences.remove(KEY_CHECKPOINT)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_CHECKPOINTS = 16
|
||||
val KEY_CHECKPOINT = stringPreferencesKey("chat_inflight_turn_checkpoint_v1")
|
||||
val KEY_CHECKPOINT_SET = stringPreferencesKey("chat_inflight_turn_checkpoints_v2")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mergeChatTurnCheckpoints(
|
||||
existing: List<ChatTurnCheckpoint>,
|
||||
checkpoint: ChatTurnCheckpoint,
|
||||
now: Long,
|
||||
limit: Int = 16,
|
||||
): List<ChatTurnCheckpoint> =
|
||||
(existing.filterNot {
|
||||
it.contextKey == checkpoint.contextKey && it.sessionId == checkpoint.sessionId
|
||||
} + checkpoint)
|
||||
.filter {
|
||||
it.schemaVersion == ChatTurnCheckpoint.CURRENT_SCHEMA &&
|
||||
now - it.updatedAt <= ChatTurnCheckpoint.MAX_AGE_MS
|
||||
}
|
||||
.sortedByDescending(ChatTurnCheckpoint::updatedAt)
|
||||
.take(limit)
|
||||
|
||||
internal fun removeChatTurnCheckpoint(
|
||||
existing: List<ChatTurnCheckpoint>,
|
||||
contextKey: String,
|
||||
sessionId: String,
|
||||
): List<ChatTurnCheckpoint> = existing.filterNot {
|
||||
it.contextKey == contextKey && it.sessionId == sessionId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ChatTurnCheckpointSet(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA,
|
||||
val checkpoints: List<ChatTurnCheckpoint>,
|
||||
) {
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Pure, persisted-state-derived availability for a Hermes connection.
|
||||
*
|
||||
* This deliberately describes configured surfaces, not live reachability or
|
||||
* authentication. Runtime layers can combine it with their probe/auth state
|
||||
* without treating a missing optional API server or Relay as a broken Hermes
|
||||
* connection.
|
||||
*/
|
||||
data class ConnectionCapabilities(
|
||||
val dashboardGatewayConfigured: Boolean,
|
||||
val apiServerConfigured: Boolean,
|
||||
val relayConfigured: Boolean,
|
||||
) {
|
||||
val gatewayChatAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val manageAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val standardVoiceAvailable: Boolean get() = dashboardGatewayConfigured
|
||||
val apiChatFallbackAvailable: Boolean get() = apiServerConfigured
|
||||
val relayFeaturesAvailable: Boolean get() = relayConfigured
|
||||
val chatConfigured: Boolean get() = gatewayChatAvailable || apiChatFallbackAvailable
|
||||
val anySurfaceConfigured: Boolean
|
||||
get() = dashboardGatewayConfigured || apiServerConfigured || relayConfigured
|
||||
}
|
||||
|
||||
val Connection.capabilities: ConnectionCapabilities
|
||||
get() = ConnectionCapabilities(
|
||||
dashboardGatewayConfigured = resolvedDashboardUrl.isNotBlank(),
|
||||
apiServerConfigured = apiServerUrl.isNotBlank(),
|
||||
relayConfigured = relayUrl.isNotBlank(),
|
||||
)
|
||||
@@ -13,13 +13,16 @@ data class DashboardConnectionStatus(
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
val gatewayMode: String? = null,
|
||||
val profiles: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - One or more independently-configured Hermes surfaces. Dashboard/Gateway
|
||||
* is the standard primary path; API server and Relay are optional.
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
@@ -73,17 +76,34 @@ data class Connection(
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
/** Last time the user explicitly selected this connection. */
|
||||
val lastUsedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
val transportHint: String? = null,
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
/**
|
||||
* Effective Dashboard/Gateway endpoint. Legacy records did not persist a
|
||||
* dashboard URL, so they retain the conventional same-host `:9119`
|
||||
* derivation from the API server. Dashboard-only records persist an
|
||||
* explicit URL and may leave [apiServerUrl] and [relayUrl] blank.
|
||||
*/
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
/** Stable display/host identity that does not depend on the API surface. */
|
||||
val primaryEndpointUrl: String
|
||||
get() = resolvedDashboardUrl.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
|
||||
val primaryHost: String
|
||||
get() = extractHost(primaryEndpointUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -94,6 +114,8 @@ data class Connection(
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
const val DEFAULT_API_PORT: Int = 8642
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
@@ -111,12 +133,40 @@ data class Connection(
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
apiServerUrl
|
||||
}
|
||||
fun extractDefaultLabel(apiServerUrl: String): String =
|
||||
extractHost(apiServerUrl) ?: apiServerUrl
|
||||
|
||||
/** Preserve explicit labels while upgrading an auto-generated IP label to a discovered host name. */
|
||||
fun chooseDiscoveredLabel(
|
||||
currentLabel: String,
|
||||
primaryHost: String,
|
||||
discoveredHostname: String?,
|
||||
): String {
|
||||
val current = currentLabel.trim()
|
||||
val discovered = discoveredHostname?.trim()?.takeIf { it.isNotBlank() }
|
||||
val isAutomatic = current.isBlank() || current.equals(primaryHost.trim(), ignoreCase = true)
|
||||
return if (isAutomatic && discovered != null) discovered else currentLabel
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-first label for a connection whose surfaces are optional.
|
||||
* The one-argument overload above remains for source compatibility.
|
||||
*/
|
||||
fun extractDefaultLabel(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String {
|
||||
val primary = dashboardUrl?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: apiServerUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: relayUrl.trim()
|
||||
return extractHost(primary) ?: primary
|
||||
}
|
||||
|
||||
private fun extractHost(url: String): String? = try {
|
||||
URI(url).host
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
@@ -141,6 +191,29 @@ data class Connection(
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
|
||||
fun deriveDefaultApiUrl(
|
||||
dashboardUrl: String,
|
||||
apiPort: Int = DEFAULT_API_PORT,
|
||||
): String? {
|
||||
val trimmed = dashboardUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$apiPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
@@ -150,7 +223,7 @@ data class Connection(
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
relayPort: Int = DEFAULT_RELAY_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
@@ -199,7 +272,7 @@ data class Connection(
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
"${it.role.lowercase()}|${it.routeAuthority()}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
@@ -222,13 +295,13 @@ data class Connection(
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.mapNotNull { it.mergeAuthority() }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
.filterNot { it.mergeAuthority() in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
@@ -290,10 +363,91 @@ data class Connection(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplication identity for rebuilding stored routes. Prefer the
|
||||
* legacy API authority when present so an older API-only candidate and
|
||||
* its dashboard-enriched replacement still collide. Dashboard-only
|
||||
* candidates fall back to their primary route authority.
|
||||
*/
|
||||
private fun EndpointCandidate.mergeAuthority(): String? =
|
||||
api?.let { endpoint -> "api|${endpoint.host.lowercase()}:${endpoint.port}" }
|
||||
?: routeAuthority()?.let { authority -> "route|$authority" }
|
||||
|
||||
/**
|
||||
* Build a Dashboard/Gateway-primary route from a remote host or URL.
|
||||
* API and Relay are retained only when explicitly configured; callers
|
||||
* no longer need to invent an API key or legacy surface URL.
|
||||
*/
|
||||
fun endpointCandidateFromDashboardUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
dashboardUrl: String,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null,
|
||||
): EndpointCandidate? {
|
||||
val normalizedDashboard = normalizeDashboardUrlInput(dashboardUrl)
|
||||
val dashboardUri = runCatching { URI(normalizedDashboard) }.getOrNull() ?: return null
|
||||
if (dashboardUri.scheme?.lowercase() !in setOf("http", "https") ||
|
||||
dashboardUri.host.isNullOrBlank()
|
||||
) return null
|
||||
|
||||
val api = apiServerUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { apiUrl ->
|
||||
val apiUri = runCatching { URI(apiUrl.trimEnd('/')) }.getOrNull()
|
||||
?: return@let null
|
||||
val tls = when (apiUri.scheme?.lowercase()) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return@let null
|
||||
}
|
||||
val host = apiUri.host?.takeIf { it.isNotBlank() } ?: return@let null
|
||||
ApiEndpoint(host, if (apiUri.port > 0) apiUri.port else 8642, tls)
|
||||
}
|
||||
val relay = relayUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { url ->
|
||||
val hint = when {
|
||||
url.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
url.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
RelayEndpoint(url, hint)
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(normalizedDashboard) },
|
||||
priority = priority,
|
||||
dashboard = DashboardEndpoint(normalizedDashboard),
|
||||
api = api,
|
||||
relay = relay,
|
||||
)
|
||||
}
|
||||
|
||||
fun normalizeDashboardUrlInput(
|
||||
raw: String,
|
||||
defaultPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Single source of truth for "is this connection encrypted, and by what?"
|
||||
*
|
||||
* Security is **per-surface**: a single paired connection fans out to several
|
||||
* transports (chat/gateway + Manage over the dashboard, API/sessions, relay
|
||||
* tools) and each can independently be TLS, overlay-encrypted, or plain (see
|
||||
* [computeConnectionSecurity]). Every UI surface — the chat status chip, the
|
||||
* connection header, the route picker, the detail sheet — renders the same
|
||||
* derived [ConnectionSecurity] so no two places disagree about what "secure"
|
||||
* means.
|
||||
*
|
||||
* Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard,
|
||||
* the plugin secure proxy), not just TLS. A `ws://` link over a tailnet is
|
||||
* WireGuard-encrypted end-to-end — genuinely secure, just not TLS — so it is
|
||||
* never labelled "insecure". Only a plain scheme with no overlay warns.
|
||||
*/
|
||||
enum class SurfaceSecurityKind { Tls, Overlay, Plain }
|
||||
|
||||
/** Connection-level rollup across the surfaces actually in use. */
|
||||
enum class ConnectionSecurityLevel { Tls, Overlay, Mixed, Plain, Unknown }
|
||||
|
||||
/** Security verdict for one transport surface of a connection. */
|
||||
data class SurfaceSecurity(
|
||||
val label: String,
|
||||
val kind: SurfaceSecurityKind,
|
||||
/** Human mechanism: "TLS", "Tailscale", "WireGuard", "Proxy", "Plain". */
|
||||
val mechanism: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
data class ConnectionSecurity(
|
||||
val level: ConnectionSecurityLevel,
|
||||
/** Dominant mechanism for the at-a-glance label. */
|
||||
val mechanism: String,
|
||||
val surfaces: List<SurfaceSecurity>,
|
||||
) {
|
||||
/** True when every in-use surface is encrypted (TLS or overlay). */
|
||||
val isEncrypted: Boolean
|
||||
get() = level == ConnectionSecurityLevel.Tls || level == ConnectionSecurityLevel.Overlay
|
||||
|
||||
companion object {
|
||||
val UNKNOWN = ConnectionSecurity(ConnectionSecurityLevel.Unknown, "", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the URL scheme is TLS (`wss://` / `https://`). */
|
||||
fun isTlsUrl(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
val lower = url.trim().lowercase()
|
||||
return lower.startsWith("wss://") || lower.startsWith("https://")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the active route is encrypted by an overlay network (Tailscale /
|
||||
* WireGuard) or the plugin secure proxy, even if its scheme is plain. Mirrors
|
||||
* the logic that previously lived privately in `ActiveConnectionSections`.
|
||||
*/
|
||||
fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
|
||||
if (this == null) return false
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return r == "tailscale" ||
|
||||
(isTailscaleDetected && hint.contains("tailscale")) ||
|
||||
r == "plugin_proxy" ||
|
||||
r == "plugin-proxy" ||
|
||||
hasSecureProxy() ||
|
||||
hint.contains("wireguard") ||
|
||||
hint.contains("https") ||
|
||||
hint.contains("tls")
|
||||
}
|
||||
|
||||
/** Human label for the overlay mechanism encrypting a route. */
|
||||
fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
|
||||
if (this == null) return "Encrypted"
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return when {
|
||||
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
|
||||
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
|
||||
hint.contains("wireguard") -> "WireGuard"
|
||||
hint.contains("https") || hint.contains("tls") -> "TLS"
|
||||
else -> "Encrypted"
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a single surface URL against the active route. */
|
||||
fun classifySurfaceSecurity(
|
||||
label: String,
|
||||
url: String,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): SurfaceSecurity {
|
||||
val (kind, mechanism) = when {
|
||||
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
|
||||
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
|
||||
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
|
||||
else -> SurfaceSecurityKind.Plain to "Plain"
|
||||
}
|
||||
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
|
||||
* Pure + side-effect free so it is unit-testable without Android.
|
||||
*/
|
||||
fun computeConnectionSecurity(
|
||||
apiUrl: String,
|
||||
dashboardUrl: String,
|
||||
relayUrl: String,
|
||||
relayConfigured: Boolean,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): ConnectionSecurity {
|
||||
val surfaces = buildList {
|
||||
dashboardUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Chat & Manage", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
apiUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("API / sessions", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
if (relayConfigured) {
|
||||
relayUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Relay tools", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surfaces.isEmpty()) return ConnectionSecurity.UNKNOWN
|
||||
|
||||
val kinds = surfaces.map { it.kind }.toSet()
|
||||
val hasPlain = SurfaceSecurityKind.Plain in kinds
|
||||
val hasSecure = kinds.any { it != SurfaceSecurityKind.Plain }
|
||||
|
||||
val level = when {
|
||||
!hasSecure -> ConnectionSecurityLevel.Plain
|
||||
hasPlain -> ConnectionSecurityLevel.Mixed
|
||||
kinds == setOf(SurfaceSecurityKind.Tls) -> ConnectionSecurityLevel.Tls
|
||||
else -> ConnectionSecurityLevel.Overlay
|
||||
}
|
||||
|
||||
val mechanism = when (level) {
|
||||
ConnectionSecurityLevel.Tls -> "TLS"
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
surfaces.firstOrNull { it.kind == SurfaceSecurityKind.Overlay }?.mechanism ?: "Encrypted"
|
||||
ConnectionSecurityLevel.Mixed -> "Mixed"
|
||||
ConnectionSecurityLevel.Plain -> when (activeEndpoint?.role?.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"public" -> "Public"
|
||||
null, "" -> "Plain"
|
||||
else -> activeEndpoint.role
|
||||
}
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
}
|
||||
return ConnectionSecurity(level = level, mechanism = mechanism, surfaces = surfaces)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import kotlinx.serialization.json.Json
|
||||
class ConnectionStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context?,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ class ConnectionStore private constructor(
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.relayDataStore,
|
||||
context = context.applicationContext,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -81,9 +83,13 @@ class ConnectionStore private constructor(
|
||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||
dataStore = dataStore,
|
||||
context = null,
|
||||
scope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
internal constructor(
|
||||
dataStore: DataStore<Preferences>,
|
||||
scope: CoroutineScope,
|
||||
) : this(dataStore = dataStore, context = null, scope = scope)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -100,6 +106,13 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Optional cold-start pin. `null` means restore the last connection the
|
||||
* user actively selected, which remains the recommended default.
|
||||
*/
|
||||
private val _startupConnectionId = MutableStateFlow<String?>(null)
|
||||
val startupConnectionId: StateFlow<String?> = _startupConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
@@ -128,6 +141,7 @@ class ConnectionStore private constructor(
|
||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
|
||||
val startupId = prefs[KEY_STARTUP_CONNECTION_ID]
|
||||
|
||||
// Prefer the new key. If absent and the old key has data,
|
||||
// migrate it once: write to the new key and clear the old ones
|
||||
@@ -143,15 +157,21 @@ class ConnectionStore private constructor(
|
||||
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
_connections.value = decodeConnections(oldJson)
|
||||
_activeConnectionId.value = activeOld
|
||||
val restored = decodeConnections(oldJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeOld
|
||||
Log.i(
|
||||
TAG,
|
||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||
)
|
||||
} else {
|
||||
_connections.value = decodeConnections(newJson)
|
||||
_activeConnectionId.value = activeNew
|
||||
val restored = decodeConnections(newJson)
|
||||
val validStartupId = startupId?.takeIf { id -> restored.any { it.id == id } }
|
||||
_connections.value = restored
|
||||
_startupConnectionId.value = validStartupId
|
||||
_activeConnectionId.value = validStartupId ?: activeNew
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
@@ -229,6 +249,10 @@ class ConnectionStore private constructor(
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
if (prefs[KEY_STARTUP_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -247,10 +271,12 @@ class ConnectionStore private constructor(
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
_startupConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -259,6 +285,7 @@ class ConnectionStore private constructor(
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
startupConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
@@ -266,6 +293,8 @@ class ConnectionStore private constructor(
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
val normalizedStartupId = startupConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
@@ -281,8 +310,14 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
if (normalizedStartupId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = normalizedStartupId
|
||||
}
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
_activeConnectionId.value = normalizedStartupId ?: normalizedActiveId
|
||||
_startupConnectionId.value = normalizedStartupId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
@@ -315,12 +350,44 @@ class ConnectionStore private constructor(
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.any { it.id == id }) {
|
||||
val next = current.map { connection ->
|
||||
if (connection.id == id) {
|
||||
connection.copy(lastUsedAt = System.currentTimeMillis())
|
||||
} else {
|
||||
connection
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a specific cold-start connection, or `null` to restore last used. */
|
||||
suspend fun setStartupConnection(id: String?) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val validId = id?.takeIf { candidate ->
|
||||
decodeConnections(prefs[KEY_CONNECTIONS]).any { it.id == candidate }
|
||||
}
|
||||
if (validId == null) {
|
||||
prefs.remove(KEY_STARTUP_CONNECTION_ID)
|
||||
_activeConnectionId.value?.let { activeId ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = activeId
|
||||
}
|
||||
} else {
|
||||
prefs[KEY_STARTUP_CONNECTION_ID] = validId
|
||||
}
|
||||
_startupConnectionId.value = validId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
@@ -504,6 +571,7 @@ class ConnectionStore private constructor(
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
private val KEY_STARTUP_CONNECTION_ID = stringPreferencesKey("startup_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
|
||||
@@ -52,6 +52,45 @@ object ConnectionValidation {
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/** Dashboard/Gateway URL must be HTTP(S) when configured. */
|
||||
fun validateDashboardUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "Dashboard URL",
|
||||
)
|
||||
|
||||
/** A blank API server means the optional SSE fallback is not configured. */
|
||||
fun validateOptionalApiServerUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** A blank Relay URL means Relay-only power features are not configured. */
|
||||
fun validateOptionalRelayUrl(raw: String): String? = validateOptionalUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Validate the independently optional connection surfaces. A connection
|
||||
* needs at least one endpoint, but Dashboard-only, API-only, and
|
||||
* Relay-only records are all structurally valid.
|
||||
*/
|
||||
fun validateConnectionEndpoints(
|
||||
dashboardUrl: String?,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): String? {
|
||||
if (dashboardUrl.isNullOrBlank() && apiServerUrl.isBlank() && relayUrl.isBlank()) {
|
||||
return "Configure at least one Hermes endpoint"
|
||||
}
|
||||
return validateDashboardUrl(dashboardUrl.orEmpty())
|
||||
?: validateOptionalApiServerUrl(apiServerUrl)
|
||||
?: validateOptionalRelayUrl(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
@@ -67,12 +106,33 @@ object ConnectionValidation {
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
dashboardUrl: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
if (c.id == excludeId) {
|
||||
false
|
||||
} else {
|
||||
val legacyExactMatch =
|
||||
(apiServerUrl.isNotBlank() || relayUrl.isNotBlank()) &&
|
||||
urlsEqual(c.apiServerUrl, apiServerUrl) &&
|
||||
urlsEqual(c.relayUrl, relayUrl)
|
||||
val candidateDashboard = dashboardUrl
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val dashboardMatch = !candidateDashboard.isNullOrBlank() &&
|
||||
urlsEqual(c.resolvedDashboardUrl, candidateDashboard)
|
||||
legacyExactMatch || dashboardMatch
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateOptionalUrl(
|
||||
raw: String,
|
||||
allowedSchemes: Set<String>,
|
||||
kind: String,
|
||||
): String? = if (raw.isBlank()) null else validateUrl(raw, allowedSchemes, kind)
|
||||
|
||||
private fun urlsEqual(first: String, second: String): Boolean =
|
||||
first.trim().trimEnd('/').equals(second.trim().trimEnd('/'), ignoreCase = true)
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
|
||||
@@ -72,10 +72,11 @@ class DataManager(
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
* - v6 (2026-07-19): preserves the optional pinned startup connection.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 5,
|
||||
val version: Int = 6,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
@@ -83,6 +84,7 @@ class DataManager(
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val activeConnectionId: String? = null,
|
||||
val startupConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
@@ -147,6 +149,7 @@ class DataManager(
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
@@ -159,6 +162,7 @@ class DataManager(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
startupConnectionId = connectionStore?.startupConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
@@ -172,6 +176,7 @@ class DataManager(
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
startupConnectionId = backup.startupConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
@@ -185,6 +190,7 @@ class DataManager(
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
@@ -249,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -282,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -314,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -378,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -406,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Curated, offline sample conversation for **Demo mode** — the zero-setup,
|
||||
* zero-network "Try the demo" path surfaced on the Connect screen.
|
||||
*
|
||||
* Why this exists: Hermes-Relay is a client for a *user-run* Hermes server, so
|
||||
* a fresh install with no connection has nothing to show. Google Play review
|
||||
* (and any curious first-run user) hits an empty Connect wall. Demo mode feeds
|
||||
* this canned transcript through the **real** chat pipeline
|
||||
* ([com.hermesandroid.relay.network.upstream.ChatHandler] →
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel] → `ChatScreen`), so the app
|
||||
* showcases streaming chat, Markdown, a tool-progress card, and a rich
|
||||
* [HermesCard] without a single network call. See [DemoMode] for the state
|
||||
* holder and `docs/play-store-listing.md` (App access) for the reviewer note.
|
||||
*
|
||||
* Content contract (keep it this way):
|
||||
* - **Obviously fictional, English, no real personal/server data** — public
|
||||
* repo hygiene. "Aurora Bay" is a made-up city; "Hermes" is the agent.
|
||||
* - **Fully self-contained / renders with zero network** — every message is
|
||||
* terminal (not streaming), every attachment is [AttachmentState.LOADED]
|
||||
* with no `relayToken` (which would trigger a relay fetch), and no inline
|
||||
* `http(s)` image needs to be fetched. The unit test asserts this.
|
||||
* - **Deterministic timestamps** ([DEMO_BASE_TIME] + offsets) so the demo
|
||||
* looks the same every launch and the content is unit-testable.
|
||||
*/
|
||||
object DemoContent {
|
||||
|
||||
/**
|
||||
* Fixed base wall-clock for demo timestamps (≈ mid-2025). Constant rather
|
||||
* than `System.currentTimeMillis()` so the transcript is deterministic and
|
||||
* the unit tests don't flake on timing.
|
||||
*/
|
||||
const val DEMO_BASE_TIME: Long = 1_750_000_000_000L
|
||||
|
||||
/** Stable session id for the demo conversation. */
|
||||
const val DEMO_SESSION_ID: String = "demo-session"
|
||||
|
||||
/** Display name used on the assistant bubbles in the demo. */
|
||||
const val DEMO_AGENT_NAME: String = "Hermes"
|
||||
|
||||
/**
|
||||
* The canned conversation, oldest-first (the order `ChatScreen` renders).
|
||||
* Two short exchanges: a capability tour that runs a tool and emits a rich
|
||||
* card, then a quick "can you code?" follow-up showing a Markdown code
|
||||
* block. 1–2 exchanges is enough to convey what the app does.
|
||||
*/
|
||||
fun transcript(): List<ChatMessage> = listOf(
|
||||
ChatMessage(
|
||||
id = "demo-user-1",
|
||||
role = MessageRole.USER,
|
||||
content = "Hey Hermes — what can this app do? And what's the weather in Aurora Bay?",
|
||||
timestamp = DEMO_BASE_TIME,
|
||||
clientOnly = true,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "demo-assistant-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = ASSISTANT_TOUR,
|
||||
timestamp = DEMO_BASE_TIME + 3_000L,
|
||||
agentName = DEMO_AGENT_NAME,
|
||||
badges = listOf("Demo"),
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
id = "demo-tool-1",
|
||||
name = "web_search",
|
||||
args = "{\"query\":\"weather in Aurora Bay today\"}",
|
||||
result = "Aurora Bay — 18°C, partly cloudy, wind 12 km/h NW.",
|
||||
success = true,
|
||||
isComplete = true,
|
||||
provenance = "demo",
|
||||
startedAt = DEMO_BASE_TIME + 800L,
|
||||
completedAt = DEMO_BASE_TIME + 2_300L,
|
||||
),
|
||||
),
|
||||
cards = listOf(
|
||||
HermesCard(
|
||||
type = HermesCard.BuiltInTypes.WEATHER,
|
||||
title = "Aurora Bay",
|
||||
subtitle = "Partly cloudy",
|
||||
accent = HermesCard.Accents.INFO,
|
||||
fields = listOf(
|
||||
HermesCardField("Now", "18°C · feels like 17°C"),
|
||||
HermesCardField("Wind", "12 km/h NW"),
|
||||
HermesCardField("Sunset", "8:42 PM"),
|
||||
),
|
||||
footer = "Sample data — demo mode",
|
||||
id = "demo-weather",
|
||||
),
|
||||
),
|
||||
clientOnly = true,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "demo-user-2",
|
||||
role = MessageRole.USER,
|
||||
content = "Nice! Can you write code too?",
|
||||
timestamp = DEMO_BASE_TIME + 9_000L,
|
||||
clientOnly = true,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "demo-assistant-2",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = ASSISTANT_CODE,
|
||||
timestamp = DEMO_BASE_TIME + 12_000L,
|
||||
agentName = DEMO_AGENT_NAME,
|
||||
badges = listOf("Demo"),
|
||||
clientOnly = true,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Assistant reply appended when the user sends a message INSIDE demo
|
||||
* mode. The composer must not be a silent no-op (it reads as broken —
|
||||
* see the demo-polish TODO), but there is no server to answer, so the
|
||||
* "reply" is an honest notice pointing at the exit path. Same content
|
||||
* contract as the transcript: clientOnly, terminal, zero network.
|
||||
*
|
||||
* @param id unique message id supplied by the caller (UUID-based; two
|
||||
* rapid sends must not collide on LazyColumn keys).
|
||||
* @param nowMs wall-clock timestamp for the bubble.
|
||||
*/
|
||||
fun composerReply(id: String, nowMs: Long): ChatMessage = ChatMessage(
|
||||
id = id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = COMPOSER_REPLY,
|
||||
timestamp = nowMs,
|
||||
agentName = DEMO_AGENT_NAME,
|
||||
badges = listOf("Demo"),
|
||||
clientOnly = true,
|
||||
)
|
||||
|
||||
// --- Message bodies (Markdown). Kept as constants so the content is easy
|
||||
// to scan and the [transcript] builder stays readable. ---
|
||||
|
||||
private val COMPOSER_REPLY: String = """
|
||||
This is the offline demo, so I can't answer for real — nothing here talks to a server.
|
||||
|
||||
Connect your own Hermes server to chat live: tap **Connect** in the demo banner above.
|
||||
""".trimIndent()
|
||||
|
||||
private val ASSISTANT_TOUR: String = """
|
||||
I'm **Hermes**, the agent running on *your* server. Here's a quick tour of what this app surfaces:
|
||||
|
||||
- **Live streaming chat** with Markdown, code blocks, and reasoning
|
||||
- **Tool calls** rendered as progress cards — watch me work in real time
|
||||
- **Rich cards** for structured results like the one below
|
||||
- Optional **Terminal**, **Bridge**, and **Voice** once you connect a server
|
||||
|
||||
I just looked up the forecast for you:
|
||||
""".trimIndent()
|
||||
|
||||
private val ASSISTANT_CODE: String = """
|
||||
Absolutely — code blocks render with syntax-aware styling. For example:
|
||||
|
||||
```kotlin
|
||||
fun greet(name: String): String = "Hello, ${'$'}name!"
|
||||
|
||||
println(greet("Aurora Bay"))
|
||||
// -> Hello, Aurora Bay!
|
||||
```
|
||||
|
||||
Connect your Hermes server to chat for real, run tools, and pick up where this demo leaves off.
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Offline **Demo / Explore mode** state holder.
|
||||
*
|
||||
* Plain Kotlin (no Android, no network, no coroutines side-effects) so it can
|
||||
* be unit-tested on the pure JVM and owned by the Activity-scoped
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel] without dragging
|
||||
* framework dependencies into the demo path. The ViewModel delegates
|
||||
* `isDemoMode` to [active] and pushes [transcript] into the real `ChatHandler`
|
||||
* so the canned conversation renders through the production chat UI.
|
||||
*
|
||||
* Lifecycle: [enter] flips [active] true and loads the canned [DemoContent]
|
||||
* transcript; [exit] flips it false and clears the transcript. Entering demo
|
||||
* must **never** mark onboarding complete or start a connection — the
|
||||
* ViewModel's network entry points early-return while [active] is true (see
|
||||
* `reconnectIfStale` / `revalidate` / `connectRelay`).
|
||||
*
|
||||
* @param transcriptFactory source of the demo transcript. Defaults to
|
||||
* [DemoContent.transcript]; overridable in tests.
|
||||
*/
|
||||
class DemoMode(
|
||||
private val transcriptFactory: () -> List<ChatMessage> = DemoContent::transcript,
|
||||
) {
|
||||
private val _active = MutableStateFlow(false)
|
||||
/** True while the offline demo is active. Drives the banner + network gates. */
|
||||
val active: StateFlow<Boolean> = _active.asStateFlow()
|
||||
|
||||
private val _transcript = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
/** The canned conversation while [active]; empty otherwise. */
|
||||
val transcript: StateFlow<List<ChatMessage>> = _transcript.asStateFlow()
|
||||
|
||||
/** Enter demo: load the canned transcript, then mark active. Idempotent. */
|
||||
fun enter() {
|
||||
_transcript.value = transcriptFactory()
|
||||
_active.value = true
|
||||
}
|
||||
|
||||
/** Exit demo: clear active, then drop the transcript. Idempotent. */
|
||||
fun exit() {
|
||||
_active.value = false
|
||||
_transcript.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
@@ -38,8 +39,14 @@ import kotlinx.serialization.Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
/** Optional legacy/API-server surface. Dashboard-only routes omit it. */
|
||||
val api: ApiEndpoint? = null,
|
||||
/** Optional Hermes-Relay bridge surface. Standard upstream routes omit it. */
|
||||
val relay: RelayEndpoint? = null,
|
||||
val dashboard: DashboardEndpoint? = null,
|
||||
val proxy: ProxyEndpoint? = null,
|
||||
val security: String? = null,
|
||||
val recommended: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -61,6 +68,17 @@ data class ApiEndpoint(
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard/admin surface for an [EndpointCandidate]. This is optional so
|
||||
* older v3 payloads that only carried API + Relay endpoints keep
|
||||
* deserializing; when absent, Android derives the conventional same-host
|
||||
* `:9119` dashboard URL from [ApiEndpoint].
|
||||
*/
|
||||
@Serializable
|
||||
data class DashboardEndpoint(
|
||||
val url: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
|
||||
* opens for the bridge + terminal channels.
|
||||
@@ -78,6 +96,22 @@ data class RelayEndpoint(
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Optional plugin-owned secure proxy route. Unlike [api], [dashboard], and
|
||||
* [relay], this is one app-facing base that can cover all Hermes-Relay
|
||||
* supported traffic after pairing. It is deliberately optional so plugin
|
||||
* proxy support can be advertised by newer payloads without changing the
|
||||
* standard upstream connection model.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProxyEndpoint(
|
||||
val url: String,
|
||||
@SerialName("transport_hint")
|
||||
val transportHint: String? = null,
|
||||
@SerialName("pin_sha256")
|
||||
val pinSha256: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
|
||||
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
|
||||
@@ -89,7 +123,7 @@ data class RelayEndpoint(
|
||||
*/
|
||||
fun EndpointCandidate.isKnownRole(): Boolean {
|
||||
return when (role.lowercase()) {
|
||||
"lan", "tailscale", "public" -> true
|
||||
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "https" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -106,7 +140,44 @@ fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
"public" -> if (primaryRouteUrl()?.startsWith("https://", ignoreCase = true) == true) {
|
||||
"HTTPS"
|
||||
} else {
|
||||
"Public"
|
||||
}
|
||||
"https" -> "HTTPS"
|
||||
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
|
||||
fun EndpointCandidate.primaryRouteUrl(): String? =
|
||||
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: api?.url
|
||||
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Stable host/port identity without assuming that an API surface exists. */
|
||||
fun EndpointCandidate.routeAuthority(): String? {
|
||||
val rawUrl = primaryRouteUrl() ?: return null
|
||||
val httpUrl = when {
|
||||
rawUrl.startsWith("ws://", ignoreCase = true) -> "http://${rawUrl.substringAfter("://")}"
|
||||
rawUrl.startsWith("wss://", ignoreCase = true) -> "https://${rawUrl.substringAfter("://")}"
|
||||
else -> rawUrl
|
||||
}
|
||||
val uri = runCatching { URI(httpUrl) }.getOrNull() ?: return null
|
||||
val host = uri.host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = when {
|
||||
uri.port > 0 -> uri.port
|
||||
uri.scheme.equals("https", ignoreCase = true) -> 443
|
||||
else -> 80
|
||||
}
|
||||
return "$host:$port"
|
||||
}
|
||||
|
||||
fun EndpointCandidate.hasSecureProxy(): Boolean =
|
||||
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
||||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
||||
role.equals("plugin_proxy", ignoreCase = true) ||
|
||||
role.equals("plugin-proxy", ignoreCase = true)
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
||||
/**
|
||||
* Single source of truth for the opt-in "keep the gateway chat connection
|
||||
* Single source of truth for the opt-in "keep the app's connection to Hermes
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
|
||||