Compare commits
740 Commits
pr/chat-se
...
copilot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551685351f | ||
|
|
3fbbac07fe | ||
|
|
03bec49299 | ||
|
|
6779ba2367 | ||
|
|
8598e906ef | ||
|
|
300fc486a4 | ||
|
|
f014b46b56 | ||
|
|
833f5acda1 | ||
|
|
d03c404cb4 | ||
|
|
68ea6e521b | ||
|
|
4912e85ac8 | ||
|
|
39d8ff59aa | ||
|
|
070944f64f | ||
|
|
d4db45e8a9 | ||
|
|
451792d326 | ||
|
|
c7ca312f97 | ||
|
|
1e6e58b23b | ||
|
|
e98e71401a | ||
|
|
bec1d0d3d4 | ||
|
|
9f6ea67415 | ||
|
|
bd7443b39b | ||
|
|
93bef830ce | ||
|
|
2dfbd1c1f6 | ||
|
|
1d9f230be4 | ||
|
|
9bf295da48 | ||
|
|
eebd750781 | ||
|
|
aa11300175 | ||
|
|
7b40d1b261 | ||
|
|
2c10c601a8 | ||
|
|
578ac9f1a9 | ||
|
|
2accb47e4d | ||
|
|
b65916e0d1 | ||
|
|
9207840db4 | ||
|
|
784468d6c3 | ||
|
|
02b5f403db | ||
|
|
5d0d9e6323 | ||
|
|
64be2b2cd1 | ||
|
|
dd2400fb2a | ||
|
|
5d001cb953 | ||
|
|
d23c4a3f10 | ||
|
|
e750ad5e75 | ||
|
|
246ee490f6 | ||
|
|
d62a20fba9 | ||
|
|
7f68bf79b6 | ||
|
|
34bb7250f8 | ||
|
|
34696dc8b9 | ||
|
|
9a9afb389a | ||
|
|
1e9ae7649d | ||
|
|
5cb9026541 | ||
|
|
81e78dced5 | ||
|
|
565944ec71 | ||
|
|
ec2c69c230 | ||
|
|
f1deffa681 | ||
|
|
4b19066cc1 | ||
|
|
ea79b26b79 | ||
|
|
6eb355954c | ||
|
|
91ca52d3c5 | ||
|
|
0149d2b678 | ||
|
|
ecfddb7807 | ||
|
|
35228ecae9 | ||
|
|
cfcc4548bb | ||
|
|
21a9b3b66f | ||
|
|
837749dced | ||
|
|
59a8eecd7e | ||
|
|
542cf011a0 | ||
|
|
4355d9acca | ||
|
|
712bc74c30 | ||
|
|
0396b678fa | ||
|
|
eaf1b6bfee | ||
|
|
06cb2bf58d | ||
|
|
8fdb3b38eb | ||
|
|
5689d7fb98 | ||
|
|
2424404fb4 | ||
|
|
17a09cc721 | ||
|
|
bc4d8ce398 | ||
|
|
279f799388 | ||
|
|
1d658109a8 | ||
|
|
5a446f3a21 | ||
|
|
52b6bf04af | ||
|
|
76a42da676 | ||
|
|
51a9053387 | ||
|
|
db0235a26a | ||
|
|
fac21e6eb4 | ||
|
|
e872f5335b | ||
|
|
a23e272877 | ||
|
|
870bfa94ed | ||
|
|
d297e17958 | ||
|
|
6a25e23909 | ||
|
|
dc66527114 | ||
|
|
110b5dafee | ||
|
|
5fd699d0bf | ||
|
|
c1e50b7184 | ||
|
|
c7e0dc10fc | ||
|
|
01579aa7d7 | ||
|
|
42cd8a02bb | ||
|
|
96f1846c2c | ||
|
|
7c336588ea | ||
|
|
814e9a500e | ||
|
|
370896e994 | ||
|
|
573354f5e4 | ||
|
|
c721947346 | ||
|
|
56339a17cc | ||
|
|
567d8e5aa4 | ||
|
|
da3a141c58 | ||
|
|
c0c8ee217f | ||
|
|
411ce7e231 | ||
|
|
b709898fb3 | ||
|
|
826013c990 | ||
|
|
482fcd2f2c | ||
|
|
6c7f224ce1 | ||
|
|
db146837a1 | ||
|
|
1ef2de1276 | ||
|
|
60cbf97079 | ||
|
|
13a62d1a6f | ||
|
|
534f28a78f | ||
|
|
3993c9a3b4 | ||
|
|
f552820a75 | ||
|
|
784ea4f7d5 | ||
|
|
f07a58965e | ||
|
|
773dad256e | ||
|
|
ffca65d15f | ||
|
|
654b6a943b | ||
|
|
768d5ccafe | ||
|
|
8b3cb373d4 | ||
|
|
9d09a7879c | ||
|
|
495a39b5a9 | ||
|
|
ba824a4b2d | ||
|
|
8b6b97c3f6 | ||
|
|
47e440f73a | ||
|
|
80c1edc3ff | ||
|
|
1d55dc0fe3 | ||
|
|
57e81d3c24 | ||
|
|
cd6bacae23 | ||
|
|
447db67b18 | ||
|
|
019726f2d1 | ||
|
|
3be7ac8524 | ||
|
|
058f00ba0b | ||
|
|
fb85cb3271 | ||
|
|
d47db55106 | ||
|
|
5045a9a00d | ||
|
|
36a2584ac7 | ||
|
|
cadaf2c835 | ||
|
|
72455b902f | ||
|
|
e389bd478b | ||
|
|
0873351401 | ||
|
|
ced9efd964 | ||
|
|
6822d509d7 | ||
|
|
9f588d91f4 | ||
|
|
486af3f453 | ||
|
|
7a283f86a8 | ||
|
|
917bcb714e | ||
|
|
646ea6ef0b | ||
|
|
3d8a759eba | ||
|
|
4c8806ad38 | ||
|
|
0824bc0236 | ||
|
|
0e17e55be9 | ||
|
|
54e0fc342e | ||
|
|
cc8506ae79 | ||
|
|
f2606a17ba | ||
|
|
1a4fade2f7 | ||
|
|
e344b7df9c | ||
|
|
256fdcb3cf | ||
|
|
acdfbee4f9 | ||
|
|
ff69a9bd9c | ||
|
|
91278d8b4e | ||
|
|
b748b86b23 | ||
|
|
1a8b106f34 | ||
|
|
87baca82db | ||
|
|
388d302472 | ||
|
|
e0c19607b7 | ||
|
|
fe77d3eb56 | ||
|
|
230211fe26 | ||
|
|
0f4e0cbe5f | ||
|
|
40b7447a80 | ||
|
|
d30e9b7d56 | ||
|
|
bc8e5ad6b3 | ||
|
|
4b3e9c0f33 | ||
|
|
d83ea7f2da | ||
|
|
7004616e03 | ||
|
|
0d37a92c16 | ||
|
|
37cbe387bf | ||
|
|
8544df36b8 | ||
|
|
3125637ad6 | ||
|
|
aadb66e956 | ||
|
|
ad6d048934 | ||
|
|
d19a0249f8 | ||
|
|
b91e72824f | ||
|
|
b573231cd1 | ||
|
|
862f34ade7 | ||
|
|
d8ad865cf5 | ||
|
|
8a20f44228 | ||
|
|
a056042caa | ||
|
|
d430a3a5c7 | ||
|
|
319b4d02a0 | ||
|
|
30ca87094d | ||
|
|
98ab2b4eae | ||
|
|
b63175d822 | ||
|
|
6539c09a93 | ||
|
|
23ea4a21e0 | ||
|
|
34686027b1 | ||
|
|
7b7c107ffe | ||
|
|
36cfe75a0b | ||
|
|
d425f1ebea | ||
|
|
8580b85f0b | ||
|
|
5ff4ac7fb7 | ||
|
|
a2981c5a2c | ||
|
|
a59ac5cf6f | ||
|
|
5567bceb66 | ||
|
|
f98d31cdd3 | ||
|
|
e0896de2bf | ||
|
|
8d73c16488 | ||
|
|
0f8d0f37fd | ||
|
|
d912b02a43 | ||
|
|
4dca662a5d | ||
|
|
9063b9e61d | ||
|
|
50049fd220 | ||
|
|
9ead312118 | ||
|
|
f02960df26 | ||
|
|
b60db040e2 | ||
|
|
af42cb3ded | ||
|
|
13dab38a26 | ||
|
|
351c73be01 | ||
|
|
55ead9636c | ||
|
|
fd597a796b | ||
|
|
ff3d8cab2b | ||
|
|
9ae03b92bb | ||
|
|
5424b4173c | ||
|
|
30a8478e1a | ||
|
|
2fc926ab1c | ||
|
|
1ac1e72a47 | ||
|
|
9450873c1b | ||
|
|
f40f16608c | ||
|
|
5fb6a0fd32 | ||
|
|
3b2aff0d6f | ||
|
|
a2bea8e366 | ||
|
|
2d583e877b | ||
|
|
0c55b1e9ce | ||
|
|
51cd9c7ff4 | ||
|
|
7edc464b82 | ||
|
|
754481716e | ||
|
|
0c3d46cb72 | ||
|
|
654f9e5053 | ||
|
|
17fad54ca0 | ||
|
|
0f7f7bb95f | ||
|
|
ffbf75d740 | ||
|
|
4642fae193 | ||
|
|
5fe8c4ab8c | ||
|
|
7b8405cbfb | ||
|
|
a96e7f59c0 | ||
|
|
57f3d209de | ||
|
|
40757a8c18 | ||
|
|
472b8fe15d | ||
|
|
721737cc77 | ||
|
|
464de2978b | ||
|
|
9d22646120 | ||
|
|
f1aa260b0e | ||
|
|
b5c307d07f | ||
|
|
2e1514095d | ||
|
|
f4b3f33c8e | ||
|
|
2d1d793651 | ||
|
|
2f47b3f6bd | ||
|
|
302bb64457 | ||
|
|
de898c423b | ||
|
|
47ebe29195 | ||
|
|
cc74e0d188 | ||
|
|
d7d98c3971 | ||
|
|
5bf7a9d0db | ||
|
|
3ad0d2fe23 | ||
|
|
da98528651 | ||
|
|
75dd1781b7 | ||
|
|
1b947dcdf9 | ||
|
|
39073d5196 | ||
|
|
7725dd6795 | ||
|
|
db61451c67 | ||
|
|
9780748bbb | ||
|
|
f5cec1dd8b | ||
|
|
758f30eb7d | ||
|
|
7e1a17e5e6 | ||
|
|
4997a5b93f | ||
|
|
1092b30531 | ||
|
|
0704fe7dbb | ||
|
|
7d93de710e | ||
|
|
d51eca64cc | ||
|
|
d0f9e22a4b | ||
|
|
39b375e32b | ||
|
|
3b6ec501aa | ||
|
|
2b254a9b39 | ||
|
|
429a2d7849 | ||
|
|
1cce83b21e | ||
|
|
8255e4649c | ||
|
|
7eef176afc | ||
|
|
06e496540f | ||
|
|
f76e3c1419 | ||
|
|
bf6df6d6b7 | ||
|
|
b4776af38c | ||
|
|
cd65e8e755 | ||
|
|
28e547f120 | ||
|
|
05a254746e | ||
|
|
529372f762 | ||
|
|
3b18efdd25 | ||
|
|
6e044b5f2f | ||
|
|
310f916675 | ||
|
|
acd40e1780 | ||
|
|
b5fd66c92d | ||
|
|
45c1ccdfcf | ||
|
|
76600e80ba | ||
|
|
483a50f107 | ||
|
|
31943dcecb | ||
|
|
717fb9e413 | ||
|
|
ad7ef27f66 | ||
|
|
0d3b8f6ac3 | ||
|
|
6492e90c1b | ||
|
|
e4b3c8b98d | ||
|
|
8b8e078ef8 | ||
|
|
44a3539ffa | ||
|
|
0daaa5b592 | ||
|
|
6866cca6d7 | ||
|
|
c145a0d116 | ||
|
|
6c0a01dc90 | ||
|
|
41c9c214fc | ||
|
|
41d56c06b9 | ||
|
|
9f999f6554 | ||
|
|
9f59ff325b | ||
|
|
c415ccaed5 | ||
|
|
403904ecd1 | ||
|
|
32550154f9 | ||
|
|
6996c0f330 | ||
|
|
cf4f1ed03a | ||
|
|
c913f05fb5 | ||
|
|
88d76d4be5 | ||
|
|
b52ab96e2c | ||
|
|
f0a8b34198 | ||
|
|
64d29b0c31 | ||
|
|
9b47f463b7 | ||
|
|
9605ad76c5 | ||
|
|
c129f0bbaa | ||
|
|
9e22f019db | ||
|
|
6f58d508b8 | ||
|
|
84eadd92a1 | ||
|
|
fd918bf6bf | ||
|
|
4e1806947d | ||
|
|
8aca606a6f | ||
|
|
56799a21be | ||
|
|
d2a0e416ea | ||
|
|
43afad9f51 | ||
|
|
5d73a412c6 | ||
|
|
d0e8faea97 | ||
|
|
cd25d69b4d | ||
|
|
c3adc50cb2 | ||
|
|
cbb9872478 | ||
|
|
39e24c9937 | ||
|
|
fa1bc589e4 | ||
|
|
0e003cb7f1 | ||
|
|
a90fe1b245 | ||
|
|
fb164b321e | ||
|
|
884211a924 | ||
|
|
9bd6b3fd54 | ||
|
|
dc06b225cd | ||
|
|
cdb35c3aae | ||
|
|
4e4f5558fc | ||
|
|
8479dc97da | ||
|
|
86ddd3c69c | ||
|
|
49d53ff0bb | ||
|
|
97e8f9d619 | ||
|
|
5392fa0dfa | ||
|
|
63d017c3af | ||
|
|
40646c73af | ||
|
|
43ea7665ef | ||
|
|
ba131b0164 | ||
|
|
0693c7804f | ||
|
|
6c69ea2c91 | ||
|
|
1e10dc1d3b | ||
|
|
c22a37976d | ||
|
|
9b9bbae501 | ||
|
|
7bfc32fe33 | ||
|
|
b073deee20 | ||
|
|
89c5035aa2 | ||
|
|
cb7791c8a4 | ||
|
|
9a14267dfa | ||
|
|
010d305401 | ||
|
|
3210c91f6b | ||
|
|
e3cea55d72 | ||
|
|
687a902f3e | ||
|
|
fe860de148 | ||
|
|
bc8a59faa4 | ||
|
|
91bcdad503 | ||
|
|
ab97c6880b | ||
|
|
65dd73b4c3 | ||
|
|
b69aa011fe | ||
|
|
e3a44b10bc | ||
|
|
5b8007784b | ||
|
|
0d6e78b718 | ||
|
|
46ab4cb19e | ||
|
|
32edaad823 | ||
|
|
5dcd48544a | ||
|
|
1e05925e47 | ||
|
|
fb47f1cbeb | ||
|
|
15d1421cf2 | ||
|
|
899bbd40d7 | ||
|
|
555b2578a8 | ||
|
|
0229b8bbd8 | ||
|
|
552f9eff7b | ||
|
|
36e0cffaaf | ||
|
|
e17a9c6abf | ||
|
|
6180603ef4 | ||
|
|
810374d648 | ||
|
|
968b967854 | ||
|
|
110079d99d | ||
|
|
34a126a6d7 | ||
|
|
31462f64d8 | ||
|
|
de0a488985 | ||
|
|
15f16de651 | ||
|
|
b46855d8c4 | ||
|
|
feaad8250b | ||
|
|
fa7df1976d | ||
|
|
2cd62f94a5 | ||
|
|
a74c19feed | ||
|
|
1ad4a7194e | ||
|
|
beec504ebd | ||
|
|
fe1133e2c5 | ||
|
|
6f37f1d8ff | ||
|
|
57700f33a9 | ||
|
|
2700794228 | ||
|
|
416894c642 | ||
|
|
db88378ae3 | ||
|
|
e97b4973bb | ||
|
|
832dfb02fe | ||
|
|
15e3a2a395 | ||
|
|
8c472c210f | ||
|
|
833bbcd166 | ||
|
|
d7440baef6 | ||
|
|
58b131919f | ||
|
|
186e86660a | ||
|
|
18d47b47d2 | ||
|
|
eb1e2c7a3b | ||
|
|
6ea4cb0012 | ||
|
|
184f5a5fc3 | ||
|
|
4ad359ffcd | ||
|
|
38cc2a3288 | ||
|
|
28c49db494 | ||
|
|
026e6c4df4 | ||
|
|
841dfc693e | ||
|
|
f38278d919 | ||
|
|
9545edcb49 | ||
|
|
f3554a3ad8 | ||
|
|
b30359e9cd | ||
|
|
d3898ee8df | ||
|
|
d1c2fc4bc8 | ||
|
|
a5a3ab958f | ||
|
|
165861e78d | ||
|
|
e7c355ee85 | ||
|
|
052a58f2f7 | ||
|
|
5ff56ffb4e | ||
|
|
9a3dd626a1 | ||
|
|
aae4b2952f | ||
|
|
aec622fe63 | ||
|
|
e6287270d9 | ||
|
|
c05a7b5390 | ||
|
|
020fecef5c | ||
|
|
caf9dec89c | ||
|
|
438a41f91f | ||
|
|
a0cd295c0f | ||
|
|
14d3d72bcc | ||
|
|
03916ed10e | ||
|
|
5bd55037e4 | ||
|
|
ec51bb700c | ||
|
|
051d518078 | ||
|
|
294886b54f | ||
|
|
6629e31789 | ||
|
|
9d7087168f | ||
|
|
daceeaa24c | ||
|
|
778800be70 | ||
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c | ||
|
|
e81ca7ab00 | ||
|
|
27acfa59c5 | ||
|
|
b333c4a994 | ||
|
|
23f7dd8b25 | ||
|
|
77d9451712 | ||
|
|
a4fc2b4536 | ||
|
|
20a7dd8a80 | ||
|
|
450d2d25e2 | ||
|
|
df024afc97 | ||
|
|
12168dc64f | ||
|
|
4232081fcb | ||
|
|
17f3635109 | ||
|
|
9206d21c76 | ||
|
|
96be166bd6 | ||
|
|
d8abd53a1d | ||
|
|
eff292eda4 | ||
|
|
c74551c2ae | ||
|
|
48b0d08493 | ||
|
|
dd38185e6c | ||
|
|
ec01e5c7e6 | ||
|
|
e447233533 | ||
|
|
00bcb01bb4 | ||
|
|
458850483a | ||
|
|
76bae8da40 | ||
|
|
c33c0629ec | ||
|
|
e5ea8a0d22 | ||
|
|
e083f678fd | ||
|
|
51dfd6efdb | ||
|
|
4fad74738a | ||
|
|
69f0469530 | ||
|
|
eb1ee36f59 | ||
|
|
b341512564 | ||
|
|
6734f2d71c | ||
|
|
e12abf3114 | ||
|
|
4ad9622efb | ||
|
|
2f0dd9c4ee | ||
|
|
2af497495f | ||
|
|
056b3e40d6 | ||
|
|
6402a48482 | ||
|
|
2dfd3b9a81 | ||
|
|
7b6cbf5869 | ||
|
|
8686b3b951 | ||
|
|
2e7e135bc0 | ||
|
|
c287664923 | ||
|
|
18f0051d26 | ||
|
|
b012b1105e | ||
|
|
21370fc09b | ||
|
|
4999f15688 | ||
|
|
e4f9555f21 | ||
|
|
243a8b019e | ||
|
|
5c4079f66c | ||
|
|
b1f086b536 | ||
|
|
d298b8c16b | ||
|
|
40968bd5e0 | ||
|
|
80e6c070bf | ||
|
|
26fcca087b | ||
|
|
02ca148583 | ||
|
|
ae1c6f4313 | ||
|
|
9faed2226a | ||
|
|
cf04b24632 | ||
|
|
7e59c15496 | ||
|
|
9f856abfe7 | ||
|
|
e74fd9196c | ||
|
|
40e928a4c4 | ||
|
|
079af0d0b0 | ||
|
|
faa5838147 | ||
|
|
f6abe62e5f | ||
|
|
5c5745dee5 | ||
|
|
15c735de4d | ||
|
|
8bf484bdad | ||
|
|
36719690a2 | ||
|
|
f2666d2092 | ||
|
|
a28c271488 | ||
|
|
1d9d5b30ce | ||
|
|
14f56a4e18 | ||
|
|
687c41e838 | ||
|
|
ddb7b5c6a4 | ||
|
|
262e35c219 | ||
|
|
95f0befd65 | ||
|
|
83d5e30027 | ||
|
|
842be7b864 | ||
|
|
cb5d76ed3d | ||
|
|
3d5ffee07f | ||
|
|
bd8f4b052d | ||
|
|
929d50b7d1 | ||
|
|
4fda10c508 | ||
|
|
0b0d8b2406 | ||
|
|
844ff2ee8f | ||
|
|
8c666666ef | ||
|
|
2394703593 | ||
|
|
404470853a | ||
|
|
99fc0fbac1 | ||
|
|
91ed00f800 | ||
|
|
76698ed296 | ||
|
|
716546824f | ||
|
|
74f382f732 | ||
|
|
a76aea1bc0 | ||
|
|
533766207f | ||
|
|
59fa002561 | ||
|
|
48ab168df2 | ||
|
|
bef9d5bdc8 | ||
|
|
c6812c6af4 | ||
|
|
1f7cb4b853 | ||
|
|
d161f3ab0f | ||
|
|
c65b91c841 | ||
|
|
760b1e8fc6 | ||
|
|
188893f319 | ||
|
|
04ee9e7765 | ||
|
|
390ba5f42a | ||
|
|
b8593fd4fb | ||
|
|
68a467dd66 | ||
|
|
d18319a57d | ||
|
|
15e5bb3459 | ||
|
|
41f6d06967 | ||
|
|
e3a99aa2ce | ||
|
|
c1d8456860 | ||
|
|
da10ca1585 | ||
|
|
5d017dae5a | ||
|
|
30fd7001f2 | ||
|
|
da4b124480 | ||
|
|
710c681283 | ||
|
|
e45228ac37 | ||
|
|
a0180f364d | ||
|
|
d69f246ba7 | ||
|
|
a81989048d | ||
|
|
b56e9964f5 | ||
|
|
ddd7fc1513 | ||
|
|
4ebf55f1db | ||
|
|
cc24ede586 | ||
|
|
eb3b84f3d2 | ||
|
|
304244f2be | ||
|
|
f067ea25b4 | ||
|
|
fa51294f65 | ||
|
|
a4d1c4d522 | ||
|
|
6e17c463ae | ||
|
|
63797e841d | ||
|
|
fdb171cb15 | ||
|
|
6f9861bb9b | ||
|
|
759068304e | ||
|
|
ded578b1fa | ||
|
|
dcb8d16591 | ||
|
|
06c17a333e | ||
|
|
409a16060b | ||
|
|
7720106624 | ||
|
|
c613769d22 | ||
|
|
87343c374e | ||
|
|
67be9aed28 | ||
|
|
b48d5d96d3 | ||
|
|
d8cc7db5e6 | ||
|
|
dfbf6ac263 | ||
|
|
121ae6036b | ||
|
|
6e1ad31b49 | ||
|
|
b8b0b3f0e7 | ||
|
|
0330b483ad | ||
|
|
9a2bf57e1c | ||
|
|
439044068a | ||
|
|
4c3b4aeb76 | ||
|
|
1e8b291374 | ||
|
|
95f82154f7 | ||
|
|
7bc3998451 | ||
|
|
d029ceab1c | ||
|
|
c331bdc27d | ||
|
|
b0b42b4e14 | ||
|
|
e5514d4854 | ||
|
|
20bc89d96c | ||
|
|
199fef2a5e | ||
|
|
d9a2ac7e72 | ||
|
|
a16934b2ab | ||
|
|
14a072f5fa | ||
|
|
574b848863 | ||
|
|
2e6c58bf75 | ||
|
|
a5d89e6eb1 | ||
|
|
61907ddf3e | ||
|
|
1eab8fa9b0 | ||
|
|
2cf444be02 | ||
|
|
7870ce8177 | ||
|
|
e9d691d472 | ||
|
|
ac2fcfe96a | ||
|
|
627fa3083b | ||
|
|
e4877656ca | ||
|
|
d91f0ceeb3 | ||
|
|
9b71382efb | ||
|
|
dd82d32d85 | ||
|
|
5a42f7cabd | ||
|
|
f2c25c5f40 | ||
|
|
528524e4c7 | ||
|
|
7e08de4a5f | ||
|
|
c9e3c14f9c | ||
|
|
636a8e3181 | ||
|
|
660f87278c | ||
|
|
9ec1fb4a80 | ||
|
|
74757cd5af | ||
|
|
9f75550702 | ||
|
|
8214ab507c | ||
|
|
2f6d5805de | ||
|
|
48f733e4b3 | ||
|
|
e5f7435d9f | ||
|
|
322c8dc4fc | ||
|
|
cf04b0e3bf | ||
|
|
0f9f510dd9 | ||
|
|
e110cf4fb1 | ||
|
|
6942ceb7a9 | ||
|
|
47cf28f6b6 | ||
|
|
115b4379bf | ||
|
|
a6db1edee3 | ||
|
|
4ed1b7c7ed | ||
|
|
7ef7b94bc0 | ||
|
|
da7da30b22 | ||
|
|
213d9b47b0 | ||
|
|
d88b239d3c | ||
|
|
1c02de1309 | ||
|
|
d3c2b83f88 | ||
|
|
93f80894a3 | ||
|
|
c440cc2f84 | ||
|
|
8ddedc3fc5 | ||
|
|
2439c31844 | ||
|
|
292f21ae78 | ||
|
|
9c2c4b1138 | ||
|
|
e0e33e12d1 | ||
|
|
eb5145c5d1 | ||
|
|
aedf4ce328 | ||
|
|
94af5a72fc | ||
|
|
9dbc1435a6 | ||
|
|
32a668e4d9 | ||
|
|
c17c7b4e24 | ||
|
|
c5e732951b | ||
|
|
069b50635b | ||
|
|
074db1905a | ||
|
|
9609a3af40 | ||
|
|
476087f879 | ||
|
|
41b696fa83 | ||
|
|
a5adedea91 | ||
|
|
9266e542ab | ||
|
|
f06ad4502b | ||
|
|
e26c647828 | ||
|
|
51f1f23235 | ||
|
|
c9d02f0132 | ||
|
|
bee72f1ae0 | ||
|
|
509bc81e28 | ||
|
|
6d5195c890 | ||
|
|
11b9b6dba5 | ||
|
|
bbc67f3754 | ||
|
|
4f02c74dca | ||
|
|
a5afe7bc2b | ||
|
|
f4b03599f0 | ||
|
|
7f6e87e918 | ||
|
|
e14ff8f407 | ||
|
|
87d995bcde | ||
|
|
44d55667de | ||
|
|
3e546e691d | ||
|
|
5b0684ebcf | ||
|
|
0bd99717be | ||
|
|
154c49511c | ||
|
|
34462b3221 | ||
|
|
0372bdf6fe | ||
|
|
cd8309cc31 | ||
|
|
c3a34408f3 | ||
|
|
9f280454ba | ||
|
|
5d9a5b7958 | ||
|
|
ed909d6013 | ||
|
|
9497ffcc50 | ||
|
|
032c780a79 | ||
|
|
e011c764a7 | ||
|
|
b2650ba672 | ||
|
|
147fccd967 |
BIN
.agent/.DS_Store
vendored
Normal file
BIN
.agent/.DS_Store
vendored
Normal file
Binary file not shown.
366
.agent/workflows/update_clawdbot.md
Normal file
366
.agent/workflows/update_clawdbot.md
Normal file
@@ -0,0 +1,366 @@
|
||||
---
|
||||
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
|
||||
---
|
||||
|
||||
# Clawdbot Upstream Sync Workflow
|
||||
|
||||
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check divergence status
|
||||
git fetch upstream && git rev-list --left-right --count main...upstream/main
|
||||
|
||||
# Full sync (rebase preferred)
|
||||
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
|
||||
|
||||
# Check for Swift 6.2 issues after sync
|
||||
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Assess Divergence
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git log --oneline --left-right main...upstream/main | head -20
|
||||
```
|
||||
|
||||
This shows:
|
||||
- `<` = your local commits (ahead)
|
||||
- `>` = upstream commits you're missing (behind)
|
||||
|
||||
**Decision point:**
|
||||
- Few local commits, many upstream → **Rebase** (cleaner history)
|
||||
- Many local commits or shared branch → **Merge** (preserves history)
|
||||
|
||||
---
|
||||
|
||||
## Step 2A: Rebase Strategy (Preferred)
|
||||
|
||||
Replays your commits on top of upstream. Results in linear history.
|
||||
|
||||
```bash
|
||||
# Ensure working tree is clean
|
||||
git status
|
||||
|
||||
# Rebase onto upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### Handling Rebase Conflicts
|
||||
|
||||
```bash
|
||||
# When conflicts occur:
|
||||
# 1. Fix conflicts in the listed files
|
||||
# 2. Stage resolved files
|
||||
git add <resolved-files>
|
||||
|
||||
# 3. Continue rebase
|
||||
git rebase --continue
|
||||
|
||||
# If a commit is no longer needed (already in upstream):
|
||||
git rebase --skip
|
||||
|
||||
# To abort and return to original state:
|
||||
git rebase --abort
|
||||
```
|
||||
|
||||
### Common Conflict Patterns
|
||||
|
||||
| File | Resolution |
|
||||
|------|------------|
|
||||
| `package.json` | Take upstream deps, keep local scripts if needed |
|
||||
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
|
||||
| `*.patch` files | Usually take upstream version |
|
||||
| Source files | Merge logic carefully, prefer upstream structure |
|
||||
|
||||
---
|
||||
|
||||
## Step 2B: Merge Strategy (Alternative)
|
||||
|
||||
Preserves all history with a merge commit.
|
||||
|
||||
```bash
|
||||
git merge upstream/main --no-edit
|
||||
```
|
||||
|
||||
Resolve conflicts same as rebase, then:
|
||||
```bash
|
||||
git add <resolved-files>
|
||||
git commit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Rebuild Everything
|
||||
|
||||
After sync completes:
|
||||
|
||||
```bash
|
||||
# Install dependencies (regenerates lock if needed)
|
||||
pnpm install
|
||||
|
||||
# Build TypeScript
|
||||
pnpm build
|
||||
|
||||
# Build UI assets
|
||||
pnpm ui:build
|
||||
|
||||
# Run diagnostics
|
||||
pnpm clawdbot doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Rebuild macOS App
|
||||
|
||||
```bash
|
||||
# Full rebuild, sign, and launch
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
# Or just package without restart
|
||||
pnpm mac:package
|
||||
```
|
||||
|
||||
### Install to /Applications
|
||||
|
||||
```bash
|
||||
# Kill running app
|
||||
pkill -x "Clawdbot" || true
|
||||
|
||||
# Move old version
|
||||
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
|
||||
|
||||
# Install new build
|
||||
cp -R dist/Clawdbot.app /Applications/
|
||||
|
||||
# Launch
|
||||
open /Applications/Clawdbot.app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4A: Verify macOS App & Agent
|
||||
|
||||
After rebuilding the macOS app, always verify it works correctly:
|
||||
|
||||
```bash
|
||||
# Check gateway health
|
||||
pnpm clawdbot health
|
||||
|
||||
# Verify no zombie processes
|
||||
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
|
||||
|
||||
# Test agent functionality by sending a verification message
|
||||
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
|
||||
|
||||
# Confirm the message was received on Telegram
|
||||
# (Check your Telegram chat with the bot)
|
||||
```
|
||||
|
||||
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
|
||||
|
||||
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
|
||||
|
||||
### Analyze-Mode Investigation
|
||||
```bash
|
||||
# Gather context with parallel agents
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
### Common Swift 6.2 Fixes
|
||||
|
||||
**FileManager.default Deprecation:**
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with proper initialization
|
||||
# OLD: FileManager.default
|
||||
# NEW: FileManager()
|
||||
```
|
||||
|
||||
**Thread.isMainThread Deprecation:**
|
||||
```bash
|
||||
# Search for deprecated usage
|
||||
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
|
||||
|
||||
# Replace with modern concurrency check
|
||||
# OLD: Thread.isMainThread
|
||||
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
|
||||
```
|
||||
|
||||
### Peekaboo Submodule Fixes
|
||||
```bash
|
||||
# Check Peekaboo for concurrency issues
|
||||
cd src/canvas-host/a2ui
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
|
||||
|
||||
# Fix and rebuild submodule
|
||||
cd /Volumes/Main SSD/Developer/clawdis
|
||||
pnpm canvas:a2ui:bundle
|
||||
```
|
||||
|
||||
### macOS App Concurrency Fixes
|
||||
```bash
|
||||
# Check macOS app for issues
|
||||
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
|
||||
|
||||
# Clean and rebuild after fixes
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Model Configuration Updates
|
||||
If upstream introduced new model configurations:
|
||||
```bash
|
||||
# Check for OpenRouter API key requirements
|
||||
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
|
||||
|
||||
# Update clawdbot.json with fallback chains
|
||||
# Add model fallback configurations as needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify & Push
|
||||
|
||||
```bash
|
||||
# Verify everything works
|
||||
pnpm clawdbot health
|
||||
pnpm test
|
||||
|
||||
# Push (force required after rebase)
|
||||
git push origin main --force-with-lease
|
||||
|
||||
# Or regular push after merge
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails After Sync
|
||||
|
||||
```bash
|
||||
# Clean and rebuild
|
||||
rm -rf node_modules dist
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Type Errors (Bun/Node Incompatibility)
|
||||
|
||||
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
|
||||
|
||||
### macOS App Crashes on Launch
|
||||
|
||||
Usually resource bundle mismatch. Full rebuild required:
|
||||
```bash
|
||||
cd apps/macos && rm -rf .build .swiftpm
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
### Patch Failures
|
||||
|
||||
```bash
|
||||
# Check patch status
|
||||
pnpm install 2>&1 | grep -i patch
|
||||
|
||||
# If patches fail, they may need updating for new dep versions
|
||||
# Check patches/ directory against package.json patchedDependencies
|
||||
```
|
||||
|
||||
### Swift 6.2 / macOS 26 SDK Build Failures
|
||||
|
||||
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
|
||||
|
||||
**Search-Mode Investigation:**
|
||||
```bash
|
||||
# Exhaustive search for deprecated APIs
|
||||
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
|
||||
```
|
||||
|
||||
**Quick Fix Commands:**
|
||||
```bash
|
||||
# Find all affected files
|
||||
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
|
||||
|
||||
# Replace FileManager.default with FileManager()
|
||||
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
|
||||
|
||||
# For Thread.isMainThread, need manual review of each usage
|
||||
grep -rn "Thread\.isMainThread" --include="*.swift" .
|
||||
```
|
||||
|
||||
**Rebuild After Fixes:**
|
||||
```bash
|
||||
# Clean all build artifacts
|
||||
rm -rf apps/macos/.build apps/macos/.swiftpm
|
||||
rm -rf src/canvas-host/a2ui/.build
|
||||
|
||||
# Rebuild Peekaboo bundle
|
||||
pnpm canvas:a2ui:bundle
|
||||
|
||||
# Full macOS rebuild
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automation Script
|
||||
|
||||
Save as `scripts/sync-upstream.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Fetching upstream..."
|
||||
git fetch upstream
|
||||
|
||||
echo "==> Current divergence:"
|
||||
git rev-list --left-right --count main...upstream/main
|
||||
|
||||
echo "==> Rebasing onto upstream/main..."
|
||||
git rebase upstream/main
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "==> Building..."
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
|
||||
echo "==> Running doctor..."
|
||||
pnpm clawdbot doctor
|
||||
|
||||
echo "==> Rebuilding macOS app..."
|
||||
./scripts/restart-mac.sh
|
||||
|
||||
echo "==> Verifying gateway health..."
|
||||
pnpm clawdbot health
|
||||
|
||||
echo "==> Checking for Swift 6.2 compatibility issues..."
|
||||
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
|
||||
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
|
||||
echo " Run manual fixes or use analyze-mode investigation"
|
||||
else
|
||||
echo "✅ No obvious Swift deprecation issues found"
|
||||
fi
|
||||
|
||||
echo "==> Testing agent functionality..."
|
||||
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
|
||||
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
|
||||
|
||||
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
|
||||
```
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -66,3 +66,6 @@ apps/ios/*.mobileprovision
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
.tgz
|
||||
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
@@ -41,6 +42,11 @@
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Release Channels (Naming)
|
||||
- stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`.
|
||||
- beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app).
|
||||
- dev: moving head on `main` (no tag; git checkout main).
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
@@ -57,6 +63,7 @@
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
@@ -94,7 +101,7 @@
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
|
||||
444
CHANGELOG.md
444
CHANGELOG.md
@@ -2,230 +2,276 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.19-3
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
||||
|
||||
### Fixes
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
||||
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest.
|
||||
|
||||
## 2026.1.19-2
|
||||
|
||||
### Changes
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type.
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
||||
- CLI: skip runner rebuilds when dist is fresh. (#1231) — thanks @mukhtharcm, @thewilloftheshadow.
|
||||
|
||||
## 2026.1.19-1
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||
|
||||
### Changes
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
|
||||
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
## 2026.1.21-2
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
|
||||
|
||||
## 2026.1.18-4
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
|
||||
### Fixes
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui
|
||||
- TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui
|
||||
- ACP: add `clawdbot acp` for IDE integrations. https://docs.clawd.bot/cli/acp
|
||||
- ACP: add `clawdbot acp client` interactive harness for debugging. https://docs.clawd.bot/cli/acp
|
||||
- Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills
|
||||
- Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser
|
||||
- Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack
|
||||
- Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram
|
||||
- Discord: fall back to `/skill` when native command limits are exceeded. (#1287)
|
||||
- Discord: expose `/skill` globally. (#1287)
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui
|
||||
- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration
|
||||
- Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
|
||||
- Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229)
|
||||
- Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229)
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||
- Exec approvals: migrate approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`. https://docs.clawd.bot/cli/node
|
||||
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session
|
||||
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding
|
||||
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||
- Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||
- Build: update workspace + core/plugin deps.
|
||||
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `clawdbot doctor --fix` to repair, then update plugins (`clawdbot plugins update`) if you use any.
|
||||
|
||||
### Fixes
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)
|
||||
- Diagnostics: gate heartbeat/webhook logging. (#1244)
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)
|
||||
- Gateway: clarify connect/validation errors for gateway params. (#1347)
|
||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337)
|
||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)
|
||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226)
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)
|
||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322)
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)
|
||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)
|
||||
- CLI: skip runner rebuilds when dist is fresh. (#1231)
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101)
|
||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)
|
||||
- UI: preserve ordered list numbering in chat markdown. (#1341)
|
||||
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212)
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298)
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201)
|
||||
- TUI: highlight model search matches and stabilize search ordering.
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182)
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`.
|
||||
- Memory: fall back to non-batch embeddings after repeated batch failures.
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
|
||||
## 2026.1.18-3
|
||||
|
||||
### Changes
|
||||
- Exec: add host/security/ask routing for gateway + node exec.
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||
|
||||
### Fixes
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Tools: centralize plugin tool policy helpers.
|
||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||
|
||||
### Fixes
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Exec approvals: enforce allowlist when ask is off.
|
||||
- Exec approvals: prefer raw command for node approvals/events.
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)
|
||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)
|
||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191)
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282)
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
- Anthropic: default API prompt caching to 1h with configurable TTL override.
|
||||
- Anthropic: ignore TTL for OAuth.
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)
|
||||
- Auth profiles: user pins stay locked. (#1138)
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)
|
||||
- Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
- Windows: install gateway scheduled task as the current user.
|
||||
- Windows: show friendly guidance instead of failing on access denied.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)
|
||||
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
|
||||
68
README.md
68
README.md
@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
@@ -258,12 +267,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
If you plan to build/run companion apps, initialize submodules first:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
./scripts/restart-mac.sh
|
||||
```
|
||||
If you plan to build/run companion apps, follow the platform runbooks below.
|
||||
|
||||
### macOS (Clawdbot.app) (optional)
|
||||
|
||||
@@ -467,33 +471,35 @@ by Peter Steinberger and the community.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
Core contributors:
|
||||
- @cpojer — Telegram onboarding UX + docs
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
|
||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
|
||||
<a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
||||
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a>
|
||||
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
||||
"originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"version" : "0.1.0"
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,8 +101,8 @@ Environment variables:
|
||||
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
|
||||
|
||||
## Development
|
||||
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
|
||||
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
|
||||
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
|
||||
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
|
||||
- Tests: `swift test` (uses swift-testing package)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
|
||||
else
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
fi
|
||||
CONFIG="${ROOT}/.swiftformat"
|
||||
swiftformat --config "$CONFIG" "$ROOT/Sources"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PEEKABOO_ROOT="${ROOT}/../peekaboo"
|
||||
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
|
||||
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
|
||||
else
|
||||
CONFIG="$ROOT/.swiftlint.yml"
|
||||
fi
|
||||
CONFIG="${ROOT}/.swiftlint.yml"
|
||||
if ! command -v swiftlint >/dev/null; then
|
||||
echo "swiftlint not installed" >&2
|
||||
exit 1
|
||||
|
||||
517
appcast.xml
517
appcast.xml
@@ -2,6 +2,270 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7374</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7116</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||
<li>Build: update workspace + core/plugin deps.</li>
|
||||
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||
</ul>
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
@@ -18,258 +282,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5825</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<h4>Web Tools</h4>
|
||||
<ul>
|
||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
||||
</ul>
|
||||
<h4>Browser / Control UI</h4>
|
||||
<ul>
|
||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
||||
</ul>
|
||||
<h4>Plugins</h4>
|
||||
<ul>
|
||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
||||
</ul>
|
||||
<h4>Security</h4>
|
||||
<ul>
|
||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
||||
</ul>
|
||||
<h4>Onboarding / Daemon</h4>
|
||||
<ul>
|
||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
||||
</ul>
|
||||
<h4>Auth / Usage / Config</h4>
|
||||
<ul>
|
||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
||||
</ul>
|
||||
<h4>Channels</h4>
|
||||
<ul>
|
||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
||||
</ul>
|
||||
<h4>Docs</h4>
|
||||
<ul>
|
||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<h4>Gateway / Daemon / Sessions</h4>
|
||||
<ul>
|
||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
||||
</ul>
|
||||
<h4>CLI / Onboarding</h4>
|
||||
<ul>
|
||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
||||
</ul>
|
||||
<h4>Control UI / TUI</h4>
|
||||
<ul>
|
||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
||||
</ul>
|
||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
||||
<ul>
|
||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
||||
</ul>
|
||||
<h4>macOS / Apps</h4>
|
||||
<ul>
|
||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h4>Channels / Messaging</h4>
|
||||
<ul>
|
||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
||||
<li>WhatsApp: harden owner command auth.</li>
|
||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
||||
</ul>
|
||||
<h4>Config / Doctor / Packaging</h4>
|
||||
<ul>
|
||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -1,6 +1,6 @@
|
||||
## Clawdbot Node (Android) (internal)
|
||||
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gw._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||
|
||||
Notes:
|
||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
versionCode = 202601210
|
||||
versionName = "2026.1.21"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.clawdbot.android.chat.ChatMessage
|
||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||
import com.clawdbot.android.chat.ChatSessionEntry
|
||||
import com.clawdbot.android.chat.OutgoingAttachment
|
||||
import com.clawdbot.android.gateway.DeviceAuthStore
|
||||
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||
import com.clawdbot.android.gateway.GatewayClientInfo
|
||||
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||
@@ -62,6 +63,7 @@ class NodeRuntime(context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
val prefs = SecurePrefs(appContext)
|
||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
val canvas = CanvasController()
|
||||
val camera = CameraCaptureManager(appContext)
|
||||
val location = LocationCaptureManager(appContext)
|
||||
@@ -153,6 +155,7 @@ class NodeRuntime(context: Context) {
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { name, remote, mainSessionKey ->
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
@@ -188,6 +191,7 @@ class NodeRuntime(context: Context) {
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
nodeConnected = true
|
||||
nodeStatusText = "Connected"
|
||||
@@ -525,7 +529,7 @@ class NodeRuntime(context: Context) {
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,6 +189,18 @@ class SecurePrefs(context: Context) {
|
||||
prefs.edit { putString(key, fingerprint.trim()) }
|
||||
}
|
||||
|
||||
fun getString(key: String): String? {
|
||||
return prefs.getString(key, null)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String) {
|
||||
prefs.edit { putString(key, value) }
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
prefs.edit { remove(key) }
|
||||
}
|
||||
|
||||
private fun loadOrCreateInstanceId(): String {
|
||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.clawdbot.android.gateway
|
||||
|
||||
import com.clawdbot.android.SecurePrefs
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) {
|
||||
fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
}
|
||||
|
||||
fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class GatewayDiscovery(
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_clawdbot-gateway._tcp."
|
||||
private val serviceType = "_clawdbot-gw._tcp."
|
||||
private val wideAreaDomain = "clawdbot.internal."
|
||||
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ data class GatewayConnectOptions(
|
||||
class GatewaySession(
|
||||
private val scope: CoroutineScope,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
private val deviceAuthStore: DeviceAuthStore,
|
||||
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
@@ -177,6 +178,7 @@ class GatewaySession(
|
||||
private val connectDeferred = CompletableDeferred<Unit>()
|
||||
private val closedDeferred = CompletableDeferred<Unit>()
|
||||
private val isClosed = AtomicBoolean(false)
|
||||
private val connectNonceDeferred = CompletableDeferred<String?>()
|
||||
private val client: OkHttpClient = buildClient()
|
||||
private var socket: WebSocket? = null
|
||||
private val loggerTag = "ClawdbotGateway"
|
||||
@@ -253,7 +255,8 @@ class GatewaySession(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
scope.launch {
|
||||
try {
|
||||
sendConnect()
|
||||
val nonce = awaitConnectNonce()
|
||||
sendConnect(nonce)
|
||||
} catch (err: Throwable) {
|
||||
connectDeferred.completeExceptionally(err)
|
||||
closeQuietly()
|
||||
@@ -288,16 +291,30 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendConnect() {
|
||||
val payload = buildConnectParams()
|
||||
private suspend fun sendConnect(connectNonce: String?) {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
||||
val trimmedToken = token?.trim().orEmpty()
|
||||
val authToken = if (storedToken.isNullOrBlank()) trimmedToken else storedToken
|
||||
val canFallbackToShared = !storedToken.isNullOrBlank() && trimmedToken.isNotBlank()
|
||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
||||
val res = request("connect", payload, timeoutMs = 8_000)
|
||||
if (!res.ok) {
|
||||
val msg = res.error?.message ?: "connect failed"
|
||||
if (canFallbackToShared) {
|
||||
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||
}
|
||||
throw IllegalStateException(msg)
|
||||
}
|
||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
deviceAuthStore.saveToken(identity.deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||
val sessionDefaults =
|
||||
@@ -308,7 +325,12 @@ class GatewaySession(
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
private fun buildConnectParams(): JsonObject {
|
||||
private fun buildConnectParams(
|
||||
identity: DeviceIdentity,
|
||||
connectNonce: String?,
|
||||
authToken: String,
|
||||
authPassword: String?,
|
||||
): JsonObject {
|
||||
val client = options.client
|
||||
val locale = Locale.getDefault().toLanguageTag()
|
||||
val clientObj =
|
||||
@@ -323,22 +345,20 @@ class GatewaySession(
|
||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||
}
|
||||
|
||||
val authToken = token?.trim().orEmpty()
|
||||
val authPassword = password?.trim().orEmpty()
|
||||
val password = authPassword?.trim().orEmpty()
|
||||
val authJson =
|
||||
when {
|
||||
authToken.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("token", JsonPrimitive(authToken))
|
||||
}
|
||||
authPassword.isNotEmpty() ->
|
||||
password.isNotEmpty() ->
|
||||
buildJsonObject {
|
||||
put("password", JsonPrimitive(authPassword))
|
||||
put("password", JsonPrimitive(password))
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val signedAtMs = System.currentTimeMillis()
|
||||
val payload =
|
||||
buildDeviceAuthPayload(
|
||||
@@ -349,6 +369,7 @@ class GatewaySession(
|
||||
scopes = options.scopes,
|
||||
signedAtMs = signedAtMs,
|
||||
token = if (authToken.isNotEmpty()) authToken else null,
|
||||
nonce = connectNonce,
|
||||
)
|
||||
val signature = identityStore.signPayload(payload, identity)
|
||||
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||
@@ -359,6 +380,9 @@ class GatewaySession(
|
||||
put("publicKey", JsonPrimitive(publicKey))
|
||||
put("signature", JsonPrimitive(signature))
|
||||
put("signedAt", JsonPrimitive(signedAtMs))
|
||||
if (!connectNonce.isNullOrBlank()) {
|
||||
put("nonce", JsonPrimitive(connectNonce))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -416,6 +440,13 @@ class GatewaySession(
|
||||
val event = frame["event"].asStringOrNull() ?: return
|
||||
val payloadJson =
|
||||
frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull()
|
||||
if (event == "connect.challenge") {
|
||||
val nonce = extractConnectNonce(payloadJson)
|
||||
if (!connectNonceDeferred.isCompleted) {
|
||||
connectNonceDeferred.complete(nonce)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||
handleInvokeEvent(payloadJson)
|
||||
return
|
||||
@@ -423,6 +454,21 @@ class GatewaySession(
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String? {
|
||||
if (isLoopbackHost(endpoint.host)) return null
|
||||
return try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractConnectNonce(payloadJson: String?): String? {
|
||||
if (payloadJson.isNullOrBlank()) return null
|
||||
val obj = parseJsonOrNull(payloadJson)?.asObjectOrNull() ?: return null
|
||||
return obj["nonce"].asStringOrNull()
|
||||
}
|
||||
|
||||
private fun handleInvokeEvent(payloadJson: String) {
|
||||
val payload =
|
||||
try {
|
||||
@@ -544,19 +590,26 @@ class GatewaySession(
|
||||
scopes: List<String>,
|
||||
signedAtMs: Long,
|
||||
token: String?,
|
||||
nonce: String?,
|
||||
): String {
|
||||
val scopeString = scopes.joinToString(",")
|
||||
val authToken = token.orEmpty()
|
||||
return listOf(
|
||||
"v1",
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
).joinToString("|")
|
||||
val version = if (nonce.isNullOrBlank()) "v1" else "v2"
|
||||
val parts =
|
||||
mutableListOf(
|
||||
version,
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopeString,
|
||||
signedAtMs.toString(),
|
||||
authToken,
|
||||
)
|
||||
if (!nonce.isNullOrBlank()) {
|
||||
parts.add(nonce)
|
||||
}
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||
|
||||
@@ -84,5 +84,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
}
|
||||
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
|
||||
@@ -160,14 +160,14 @@ actor CameraController {
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let movURL = FileManager.default.temporaryDirectory
|
||||
let movURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||
let mp4URL = FileManager.default.temporaryDirectory
|
||||
let mp4URL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: movURL)
|
||||
try? FileManager.default.removeItem(at: mp4URL)
|
||||
try? FileManager().removeItem(at: movURL)
|
||||
try? FileManager().removeItem(at: mp4URL)
|
||||
}
|
||||
|
||||
var delegate: MovieFileDelegate?
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260121</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -29,7 +29,7 @@
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_clawdbot-gateway._tcp</string>
|
||||
<string>_clawdbot-gw._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the gateway.</string>
|
||||
|
||||
@@ -837,7 +837,7 @@ final class NodeAppModel {
|
||||
fps: params.fps,
|
||||
includeAudio: params.includeAudio,
|
||||
outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||
defer { try? FileManager().removeItem(atPath: path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
struct Payload: Codable {
|
||||
var format: String
|
||||
|
||||
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
let includeAudio = includeAudio ?? true
|
||||
|
||||
let outURL = self.makeOutputURL(outPath: outPath)
|
||||
try? FileManager.default.removeItem(at: outURL)
|
||||
try? FileManager().removeItem(at: outURL)
|
||||
|
||||
return RecordConfig(
|
||||
durationMs: durationMs,
|
||||
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
])
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
self.speechRecognizer = SFSpeechRecognizer()
|
||||
guard let recognizer = self.speechRecognizer else {
|
||||
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
let input = self.audioEngine.inputNode
|
||||
let format = input.outputFormat(forBus: 0)
|
||||
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||
])
|
||||
}
|
||||
input.removeTap(onBus: 0)
|
||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
Sources/Bridge/BridgeClient.swift
|
||||
Sources/Bridge/BridgeConnectionController.swift
|
||||
Sources/Bridge/BridgeDiscoveryDebugLogView.swift
|
||||
Sources/Bridge/BridgeDiscoveryModel.swift
|
||||
Sources/Bridge/BridgeEndpointID.swift
|
||||
Sources/Bridge/BridgeSession.swift
|
||||
Sources/Bridge/BridgeSettingsStore.swift
|
||||
Sources/Bridge/KeychainStore.swift
|
||||
Sources/Gateway/GatewayConnectionController.swift
|
||||
Sources/Gateway/GatewayDiscoveryDebugLogView.swift
|
||||
Sources/Gateway/GatewayDiscoveryModel.swift
|
||||
Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSBridgeChatTransport.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/ClawdbotApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
@@ -17,6 +15,7 @@ Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
|
||||
@@ -7,11 +7,11 @@ import Testing
|
||||
@Test func stableIDForServiceDecodesAndNormalizesName() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway \\032 Node\n",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gateway._tcp|local.|Clawdbot Gateway Node")
|
||||
#expect(GatewayEndpointID.stableID(endpoint) == "_clawdbot-gw._tcp|local.|Clawdbot Gateway Node")
|
||||
}
|
||||
|
||||
@Test func stableIDForNonServiceUsesEndpointDescription() {
|
||||
@@ -22,7 +22,7 @@ import Testing
|
||||
@Test func prettyDescriptionDecodesBonjourEscapes() {
|
||||
let endpoint = NWEndpoint.service(
|
||||
name: "Clawdbot\\032Gateway",
|
||||
type: "_clawdbot-gateway._tcp",
|
||||
type: "_clawdbot-gw._tcp",
|
||||
domain: "local.",
|
||||
interface: nil)
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>20260121</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
CFBundleShortVersionString: "2026.1.21"
|
||||
CFBundleVersion: "20260121"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -92,7 +92,7 @@ targets:
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSBonjourServices:
|
||||
- _clawdbot-gateway._tcp
|
||||
- _clawdbot-gw._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the gateway.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
CFBundleShortVersionString: "2026.1.21"
|
||||
CFBundleVersion: "20260121"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,8 +12,7 @@ let package = Package(
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
||||
.executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -67,20 +66,13 @@ let package = Package(
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
name: "ClawdbotMacCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotProtocol", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
path: "Sources/ClawdbotMacCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
|
||||
@@ -81,7 +81,7 @@ private struct EventRow: View {
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private func prettyJSON(_ dict: [String: AnyCodable]) -> String? {
|
||||
private func prettyJSON(_ dict: [String: ClawdbotProtocol.AnyCodable]) -> String? {
|
||||
let normalized = dict.mapValues { $0.value }
|
||||
guard JSONSerialization.isValidJSONObject(normalized),
|
||||
let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.prettyPrinted]),
|
||||
@@ -98,7 +98,10 @@ struct AgentEventsWindow_Previews: PreviewProvider {
|
||||
seq: 1,
|
||||
stream: "tool",
|
||||
ts: Date().timeIntervalSince1970 * 1000,
|
||||
data: ["phase": AnyCodable("start"), "name": AnyCodable("bash")],
|
||||
data: [
|
||||
"phase": ClawdbotProtocol.AnyCodable("start"),
|
||||
"name": ClawdbotProtocol.AnyCodable("bash"),
|
||||
],
|
||||
summary: nil)
|
||||
AgentEventStore.shared.append(sample)
|
||||
return AgentEventsWindow()
|
||||
|
||||
@@ -23,7 +23,7 @@ enum AgentWorkspace {
|
||||
}
|
||||
|
||||
static func displayPath(for url: URL) -> String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||
let path = url.path
|
||||
if path == home { return "~" }
|
||||
if path.hasPrefix(home + "/") {
|
||||
@@ -44,12 +44,12 @@ enum AgentWorkspace {
|
||||
}
|
||||
|
||||
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
|
||||
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
|
||||
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||
}
|
||||
|
||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
var isDir: ObjCBool = false
|
||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||
return true
|
||||
@@ -66,7 +66,7 @@ enum AgentWorkspace {
|
||||
}
|
||||
|
||||
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
var isDir: ObjCBool = false
|
||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||
return .safe
|
||||
@@ -90,29 +90,29 @@ enum AgentWorkspace {
|
||||
|
||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
||||
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if !FileManager.default.fileExists(atPath: agentsURL.path) {
|
||||
if !FileManager().fileExists(atPath: agentsURL.path) {
|
||||
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
||||
}
|
||||
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
||||
if !FileManager.default.fileExists(atPath: soulURL.path) {
|
||||
if !FileManager().fileExists(atPath: soulURL.path) {
|
||||
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
||||
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
||||
}
|
||||
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
||||
if !FileManager.default.fileExists(atPath: identityURL.path) {
|
||||
if !FileManager().fileExists(atPath: identityURL.path) {
|
||||
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
||||
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
||||
}
|
||||
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
||||
if !FileManager.default.fileExists(atPath: userURL.path) {
|
||||
if !FileManager().fileExists(atPath: userURL.path) {
|
||||
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
||||
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||
}
|
||||
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
||||
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
|
||||
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
|
||||
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
||||
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
||||
}
|
||||
@@ -120,7 +120,7 @@ enum AgentWorkspace {
|
||||
}
|
||||
|
||||
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
var isDir: ObjCBool = false
|
||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||
return true
|
||||
@@ -305,7 +305,7 @@ enum AgentWorkspace {
|
||||
if let dev = self.devTemplateURL(named: named) {
|
||||
urls.append(dev)
|
||||
}
|
||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
|
||||
urls.append(cwd.appendingPathComponent("docs")
|
||||
.appendingPathComponent(self.templateDirname)
|
||||
.appendingPathComponent(named))
|
||||
|
||||
@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
||||
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
|
||||
@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
return FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
}
|
||||
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
|
||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||
}
|
||||
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
|
||||
|
||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||
let dest = self.oauthURL()
|
||||
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
|
||||
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
|
||||
|
||||
for url in self.legacyOAuthURLs() {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { continue }
|
||||
guard FileManager().fileExists(atPath: url.path) else { continue }
|
||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||
guard let storage = self.loadStorage(at: url) else { continue }
|
||||
do {
|
||||
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
|
||||
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
||||
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
|
||||
|
||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||
let dir = self.oauthDir()
|
||||
try FileManager.default.createDirectory(
|
||||
try FileManager().createDirectory(
|
||||
at: dir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
|
||||
withJSONObject: storage,
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
|
||||
// Prefer the ClawdbotKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = ClawdbotKit.AnyCodable
|
||||
typealias InstanceIdentity = ClawdbotKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
@@ -20,3 +25,23 @@ extension AnyCodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClawdbotProtocol.AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: ClawdbotProtocol.AnyCodable]? { self.value as? [String: ClawdbotProtocol.AnyCodable] }
|
||||
var arrayValue: [ClawdbotProtocol.AnyCodable]? { self.value as? [ClawdbotProtocol.AnyCodable] }
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: ClawdbotProtocol.AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [ClawdbotProtocol.AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
private static func installPrefix() -> String {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.path
|
||||
}
|
||||
|
||||
@@ -167,20 +167,20 @@ actor CameraCaptureService {
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
||||
let tmpMovURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
|
||||
defer { try? FileManager().removeItem(at: tmpMovURL) }
|
||||
|
||||
let outputURL: URL = {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
|
||||
// Ensure we don't fail exporting due to an existing file.
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
try? FileManager().removeItem(at: outputURL)
|
||||
|
||||
let logger = self.logger
|
||||
var delegate: MovieFileDelegate?
|
||||
|
||||
@@ -25,7 +25,7 @@ final class CanvasManager {
|
||||
var defaultAnchorProvider: (() -> NSRect?)?
|
||||
|
||||
private nonisolated static let canvasRoot: URL = {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
|
||||
}()
|
||||
|
||||
@@ -83,7 +83,7 @@ final class CanvasManager {
|
||||
self.panelSessionKey = nil
|
||||
|
||||
Self.logger.debug("showDetailed ensure canvas root dir")
|
||||
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
|
||||
Self.logger.debug("showDetailed init CanvasWindowController")
|
||||
let controller = try CanvasWindowController(
|
||||
sessionKey: session,
|
||||
@@ -258,7 +258,7 @@ final class CanvasManager {
|
||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||
if trimmed.hasPrefix("/") {
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||
return URL(fileURLWithPath: trimmed)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +293,7 @@ final class CanvasManager {
|
||||
}
|
||||
|
||||
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||
.map(String.init) ?? trimmed
|
||||
@@ -331,7 +331,7 @@ final class CanvasManager {
|
||||
}
|
||||
|
||||
private static func indexExists(in dir: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) { return true }
|
||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
|
||||
@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
if path.isEmpty {
|
||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
if !FileManager().fileExists(atPath: indexA.path),
|
||||
!FileManager().fileExists(atPath: indexB.path)
|
||||
{
|
||||
return self.scaffoldPage(sessionRoot: sessionRoot)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
|
||||
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
}
|
||||
|
||||
private func resolveIndex(in dir: URL) -> URL? {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) { return a }
|
||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
|
||||
@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
|
||||
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||
|
||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
if path == "/" || path.isEmpty {
|
||||
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
||||
!FileManager.default.fileExists(atPath: indexB.path)
|
||||
if !FileManager().fileExists(atPath: indexA.path),
|
||||
!FileManager().fileExists(atPath: indexB.path)
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||
if trimmed.hasPrefix("/") {
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
|
||||
let url = URL(fileURLWithPath: trimmed)
|
||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||
self.loadFile(url)
|
||||
|
||||
@@ -426,34 +426,17 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private func resolveChannelTitle(_ id: String) -> String {
|
||||
if let label = self.store.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
let label = self.store.resolveChannelLabel(id)
|
||||
if label != id { return label }
|
||||
return id.prefix(1).uppercased() + id.dropFirst()
|
||||
}
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
self.store.resolveChannelDetailLabel(id)
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
self.store.resolveChannelSystemImage(id)
|
||||
}
|
||||
|
||||
private func channelStatusDictionary(_ id: String) -> [String: AnyCodable]? {
|
||||
|
||||
@@ -153,9 +153,19 @@ struct ChannelsStatusSnapshot: Codable {
|
||||
let application: AnyCodable?
|
||||
}
|
||||
|
||||
struct ChannelUiMetaEntry: Codable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detailLabel: String
|
||||
let systemImage: String?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let channelOrder: [String]
|
||||
let channelLabels: [String: String]
|
||||
let channelDetailLabels: [String: String]?
|
||||
let channelSystemImages: [String: String]?
|
||||
let channelMeta: [ChannelUiMetaEntry]?
|
||||
let channels: [String: AnyCodable]
|
||||
let channelAccounts: [String: [ChannelAccountSnapshot]]
|
||||
let channelDefaultAccountId: [String: String]
|
||||
@@ -217,6 +227,47 @@ final class ChannelsStore {
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
|
||||
func channelMetaEntry(_ id: String) -> ChannelsStatusSnapshot.ChannelUiMetaEntry? {
|
||||
self.snapshot?.channelMeta?.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func resolveChannelLabel(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), !meta.label.isEmpty {
|
||||
return meta.label
|
||||
}
|
||||
if let label = self.snapshot?.channelLabels[id], !label.isEmpty {
|
||||
return label
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func resolveChannelDetailLabel(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), !meta.detailLabel.isEmpty {
|
||||
return meta.detailLabel
|
||||
}
|
||||
if let detail = self.snapshot?.channelDetailLabels?[id], !detail.isEmpty {
|
||||
return detail
|
||||
}
|
||||
return self.resolveChannelLabel(id)
|
||||
}
|
||||
|
||||
func resolveChannelSystemImage(_ id: String) -> String {
|
||||
if let meta = self.channelMetaEntry(id), let symbol = meta.systemImage, !symbol.isEmpty {
|
||||
return symbol
|
||||
}
|
||||
if let symbol = self.snapshot?.channelSystemImages?[id], !symbol.isEmpty {
|
||||
return symbol
|
||||
}
|
||||
return "message"
|
||||
}
|
||||
|
||||
func orderedChannelIds() -> [String] {
|
||||
if let meta = self.snapshot?.channelMeta, !meta.isEmpty {
|
||||
return meta.map(\.id)
|
||||
}
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
guard FileManager().fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = self.parseConfigData(data) else {
|
||||
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
|
||||
@@ -21,7 +21,7 @@ enum ClawdbotPaths {
|
||||
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
||||
return URL(fileURLWithPath: override, isDirectory: true)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
return FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ enum CommandResolver {
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
|
||||
if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
|
||||
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
|
||||
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
|
||||
if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,16 +47,16 @@ enum CommandResolver {
|
||||
static func projectRoot() -> URL {
|
||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||
let url = self.expandPath(stored),
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
FileManager().fileExists(atPath: url.path)
|
||||
{
|
||||
return url
|
||||
}
|
||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
||||
let fallback = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdbot")
|
||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
||||
if FileManager().fileExists(atPath: fallback.path) {
|
||||
return fallback
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
return FileManager().homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
static func setProjectRoot(_ path: String) {
|
||||
@@ -70,7 +70,7 @@ enum CommandResolver {
|
||||
static func preferredPaths() -> [String] {
|
||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||
.split(separator: ":").map(String.init) ?? []
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||
}
|
||||
@@ -99,10 +99,10 @@ enum CommandResolver {
|
||||
let bin = base.appendingPathComponent("bin")
|
||||
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||
var paths: [String] = []
|
||||
if FileManager.default.fileExists(atPath: bin.path) {
|
||||
if FileManager().fileExists(atPath: bin.path) {
|
||||
paths.append(bin.path)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
||||
if FileManager().fileExists(atPath: nodeBin.path) {
|
||||
paths.append(nodeBin.path)
|
||||
}
|
||||
return paths
|
||||
@@ -113,13 +113,13 @@ enum CommandResolver {
|
||||
|
||||
// Volta
|
||||
let volta = home.appendingPathComponent(".volta/bin")
|
||||
if FileManager.default.fileExists(atPath: volta.path) {
|
||||
if FileManager().fileExists(atPath: volta.path) {
|
||||
bins.append(volta.path)
|
||||
}
|
||||
|
||||
// asdf
|
||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||
if FileManager.default.fileExists(atPath: asdf.path) {
|
||||
if FileManager().fileExists(atPath: asdf.path) {
|
||||
bins.append(asdf.path)
|
||||
}
|
||||
|
||||
@@ -137,10 +137,10 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
||||
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
|
||||
guard FileManager().fileExists(atPath: base.path) else { return [] }
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
|
||||
entries = try FileManager().contentsOfDirectory(atPath: base.path)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -167,7 +167,7 @@ enum CommandResolver {
|
||||
for entry in sorted {
|
||||
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
||||
let node = binDir.appendingPathComponent("node")
|
||||
if FileManager.default.isExecutableFile(atPath: node.path) {
|
||||
if FileManager().isExecutableFile(atPath: node.path) {
|
||||
paths.append(binDir.path)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ enum CommandResolver {
|
||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||
for dir in searchPaths ?? self.preferredPaths() {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
if FileManager().isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -191,12 +191,12 @@ enum CommandResolver {
|
||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||
let root = projectRoot ?? self.projectRoot()
|
||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
|
||||
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func nodeCliPath() -> String? {
|
||||
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
||||
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
||||
return FileManager().isReadableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||
@@ -284,13 +284,16 @@ enum CommandResolver {
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
@@ -459,7 +462,7 @@ enum CommandResolver {
|
||||
private static func expandPath(_ path: String) -> URL? {
|
||||
var expanded = path
|
||||
if expanded.hasPrefix("~") {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
||||
}
|
||||
return URL(fileURLWithPath: expanded)
|
||||
|
||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
self.content
|
||||
HStack(spacing: 16) {
|
||||
self.sidebar
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig()
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
Group {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = self.store.configSchema {
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
||||
.disabled(self.isNixMode)
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var id: String { self.key }
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
guard let schema = self.store.configSchema else { return [] }
|
||||
return self.resolveSections(schema)
|
||||
}
|
||||
|
||||
private var activeSection: ConfigSection? {
|
||||
self.sections.first { $0.key == self.activeSectionKey }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(self.sections) { section in
|
||||
self.sidebarRow(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let section = self.activeSection {
|
||||
self.sectionDetail(section)
|
||||
} else if self.store.configSchema != nil {
|
||||
self.emptyDetail
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.header
|
||||
Text("Select a config section to view settings.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
if let status = self.store.configStatus {
|
||||
Text(status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.actionRow
|
||||
self.sectionHeader(section)
|
||||
self.subsectionNav(section)
|
||||
self.sectionForm(section)
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func sectionHeader(_ section: ConfigSection) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(section.label)
|
||||
.font(.title3.weight(.semibold))
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button("Reload") {
|
||||
@@ -85,6 +184,204 @@ extension ConfigSettings {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarRow(_ section: ConfigSection) -> some View {
|
||||
let isSelected = self.activeSectionKey == section.key
|
||||
return Button {
|
||||
self.selectSection(section)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section.label)
|
||||
if let help = section.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.background(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subsectionNav(_ section: ConfigSection) -> some View {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if subsections.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
self.subsectionButton(
|
||||
title: "All",
|
||||
isSelected: self.activeSubsection == .all)
|
||||
{
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
ForEach(subsections) { subsection in
|
||||
self.subsectionButton(
|
||||
title: subsection.label,
|
||||
isSelected: self.activeSubsection == .key(subsection.key))
|
||||
{
|
||||
self.activeSubsection = .key(subsection.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subsectionButton(
|
||||
title: String,
|
||||
isSelected: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : .primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||
if case let .key(key) = subsection,
|
||||
let match = subsections.first(where: { $0.key == key })
|
||||
{
|
||||
return (match.node, match.path)
|
||||
}
|
||||
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||
}()
|
||||
|
||||
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||
.disabled(self.isNixMode)
|
||||
}
|
||||
|
||||
private func ensureSelection() {
|
||||
guard let schema = self.store.configSchema else { return }
|
||||
let sections = self.resolveSections(schema)
|
||||
guard !sections.isEmpty else { return }
|
||||
|
||||
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||
if self.activeSectionKey != active.key {
|
||||
self.activeSectionKey = active.key
|
||||
}
|
||||
self.ensureSubsection(for: active)
|
||||
}
|
||||
|
||||
private func ensureSubsection(for section: ConfigSection) {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
guard !subsections.isEmpty else {
|
||||
self.activeSubsection = nil
|
||||
return
|
||||
}
|
||||
|
||||
switch self.activeSubsection {
|
||||
case .all:
|
||||
return
|
||||
case let .key(key):
|
||||
if subsections.contains(where: { $0.key == key }) { return }
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectSection(_ section: ConfigSection) {
|
||||
guard self.activeSectionKey != section.key else { return }
|
||||
self.activeSectionKey = section.key
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
} else {
|
||||
self.activeSubsection = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||
let node = self.resolvedSchemaNode(root)
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||
let node = self.resolvedSchemaNode(section.node)
|
||||
guard node.schemaType == "object" else { return [] }
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSubsection(
|
||||
key: key,
|
||||
label: label,
|
||||
help: help,
|
||||
node: child,
|
||||
path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
private func humanize(_ key: String) -> String {
|
||||
key.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSettings_Previews: PreviewProvider {
|
||||
|
||||
@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||
private var lastMode: AppState.ConnectionMode?
|
||||
|
||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
if let lastMode = self.lastMode, lastMode != mode {
|
||||
GatewayProcessManager.shared.clearLastFailure()
|
||||
NodesStore.shared.lastError = nil
|
||||
}
|
||||
self.lastMode = mode
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ struct ControlAgentEvent: Codable, Sendable, Identifiable {
|
||||
let seq: Int
|
||||
let stream: String
|
||||
let ts: Double
|
||||
let data: [String: AnyCodable]
|
||||
let data: [String: ClawdbotProtocol.AnyCodable]
|
||||
let summary: String?
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
private(set) var authSourceLabel: String?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||
|
||||
@@ -87,15 +88,7 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -111,7 +104,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -119,10 +112,24 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.lastPingMs = nil
|
||||
self.authSourceLabel = nil
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
@@ -156,8 +163,8 @@ final class ControlChannel {
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
let rawParams = params?.reduce(into: [String: AnyCodable]()) {
|
||||
$0[$1.key] = AnyCodable($1.value.base)
|
||||
let rawParams = params?.reduce(into: [String: ClawdbotKit.AnyCodable]()) {
|
||||
$0[$1.key] = ClawdbotKit.AnyCodable($1.value.base)
|
||||
}
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: method,
|
||||
@@ -183,8 +190,11 @@ final class ControlChannel {
|
||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||
? "gateway.remote.token"
|
||||
: "gateway.auth.token"
|
||||
return
|
||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"or clear it on the gateway. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
@@ -275,18 +285,49 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
if ok == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||
}
|
||||
await self.refreshAuthSourceLabel()
|
||||
}
|
||||
|
||||
private func refreshAuthSourceLabel() async {
|
||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||
let authSource = await GatewayConnection.shared.authSource()
|
||||
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
||||
}
|
||||
|
||||
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
||||
guard let source else { return nil }
|
||||
switch source {
|
||||
case .deviceToken:
|
||||
return "Auth: device token (paired device)"
|
||||
case .sharedToken:
|
||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||
case .password:
|
||||
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
||||
case .none:
|
||||
return "Auth: none"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
@@ -346,7 +387,7 @@ final class ControlChannel {
|
||||
let phase = event.data["phase"]?.value as? String ?? ""
|
||||
let name = event.data["name"]?.value as? String
|
||||
let meta = event.data["meta"]?.value as? String
|
||||
let args = event.data["args"]?.value as? [String: AnyCodable]
|
||||
let args = Self.bridgeToProtocolArgs(event.data["args"])
|
||||
WorkActivityStore.shared.handleTool(
|
||||
sessionKey: sessionKey,
|
||||
phase: phase,
|
||||
@@ -357,6 +398,27 @@ final class ControlChannel {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func bridgeToProtocolArgs(
|
||||
_ value: ClawdbotProtocol.AnyCodable?) -> [String: ClawdbotProtocol.AnyCodable]?
|
||||
{
|
||||
guard let value else { return nil }
|
||||
if let dict = value.value as? [String: ClawdbotProtocol.AnyCodable] {
|
||||
return dict
|
||||
}
|
||||
if let dict = value.value as? [String: ClawdbotKit.AnyCodable],
|
||||
let data = try? JSONEncoder().encode(dict),
|
||||
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(value),
|
||||
let decoded = try? JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -42,7 +42,8 @@ extension CronJobEditor {
|
||||
self.thinking = thinking ?? ""
|
||||
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
||||
self.deliver = deliver ?? false
|
||||
self.channel = GatewayAgentChannel(raw: channel)
|
||||
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.channel = trimmed.isEmpty ? "last" : trimmed
|
||||
self.to = to ?? ""
|
||||
self.bestEffortDeliver = bestEffortDeliver ?? false
|
||||
}
|
||||
@@ -210,7 +211,8 @@ extension CronJobEditor {
|
||||
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
|
||||
payload["deliver"] = self.deliver
|
||||
if self.deliver {
|
||||
payload["channel"] = self.channel.rawValue
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
|
||||
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !to.isEmpty { payload["to"] = to }
|
||||
payload["bestEffortDeliver"] = self.bestEffortDeliver
|
||||
|
||||
@@ -14,7 +14,7 @@ extension CronJobEditor {
|
||||
self.payloadKind = .agentTurn
|
||||
self.agentMessage = "Run diagnostic"
|
||||
self.deliver = true
|
||||
self.channel = .last
|
||||
self.channel = "last"
|
||||
self.to = "+15551230000"
|
||||
self.thinking = "low"
|
||||
self.timeoutSeconds = "90"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import ClawdbotProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct CronJobEditor: View {
|
||||
let job: CronJob?
|
||||
@Binding var isSaving: Bool
|
||||
@Binding var error: String?
|
||||
@Bindable var channelsStore: ChannelsStore
|
||||
let onCancel: () -> Void
|
||||
let onSave: ([String: AnyCodable]) -> Void
|
||||
|
||||
@@ -45,13 +47,29 @@ struct CronJobEditor: View {
|
||||
@State var systemEventText: String = ""
|
||||
@State var agentMessage: String = ""
|
||||
@State var deliver: Bool = false
|
||||
@State var channel: GatewayAgentChannel = .last
|
||||
@State var channel: String = "last"
|
||||
@State var to: String = ""
|
||||
@State var thinking: String = ""
|
||||
@State var timeoutSeconds: String = ""
|
||||
@State var bestEffortDeliver: Bool = false
|
||||
@State var postPrefix: String = "Cron"
|
||||
|
||||
var channelOptions: [String] {
|
||||
let ordered = self.channelsStore.orderedChannelIds()
|
||||
var options = ["last"] + ordered
|
||||
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty, !options.contains(trimmed) {
|
||||
options.append(trimmed)
|
||||
}
|
||||
var seen = Set<String>()
|
||||
return options.filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
func channelLabel(for id: String) -> String {
|
||||
if id == "last" { return "last" }
|
||||
return self.channelsStore.resolveChannelLabel(id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -333,13 +351,9 @@ struct CronJobEditor: View {
|
||||
GridRow {
|
||||
self.gridLabel("Channel")
|
||||
Picker("", selection: self.$channel) {
|
||||
Text("last").tag(GatewayAgentChannel.last)
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
Text("slack").tag(GatewayAgentChannel.slack)
|
||||
Text("signal").tag(GatewayAgentChannel.signal)
|
||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
||||
ForEach(self.channelOptions, id: \.self) { channel in
|
||||
Text(self.channelLabel(for: channel)).tag(channel)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -8,13 +8,20 @@ extension CronSettings {
|
||||
self.content
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.onAppear { self.store.start() }
|
||||
.onDisappear { self.store.stop() }
|
||||
.onAppear {
|
||||
self.store.start()
|
||||
self.channelsStore.start()
|
||||
}
|
||||
.onDisappear {
|
||||
self.store.stop()
|
||||
self.channelsStore.stop()
|
||||
}
|
||||
.sheet(isPresented: self.$showEditor) {
|
||||
CronJobEditor(
|
||||
job: self.editingJob,
|
||||
isSaving: self.$isSaving,
|
||||
error: self.$editorError,
|
||||
channelsStore: self.channelsStore,
|
||||
onCancel: {
|
||||
self.showEditor = false
|
||||
self.editingJob = nil
|
||||
|
||||
@@ -47,7 +47,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
durationMs: 1234,
|
||||
nextRunAtMs: nil),
|
||||
]
|
||||
return CronSettings(store: store)
|
||||
return CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ extension CronSettings {
|
||||
store.selectedJobId = job.id
|
||||
store.runEntries = [run]
|
||||
|
||||
let view = CronSettings(store: store)
|
||||
let view = CronSettings(store: store, channelsStore: ChannelsStore(isPreview: true))
|
||||
_ = view.body
|
||||
_ = view.jobRow(job)
|
||||
_ = view.jobContextMenu(job)
|
||||
|
||||
@@ -3,13 +3,15 @@ import SwiftUI
|
||||
|
||||
struct CronSettings: View {
|
||||
@Bindable var store: CronJobsStore
|
||||
@Bindable var channelsStore: ChannelsStore
|
||||
@State var showEditor = false
|
||||
@State var editingJob: CronJob?
|
||||
@State var editorError: String?
|
||||
@State var isSaving = false
|
||||
@State var confirmDelete: CronJob?
|
||||
|
||||
init(store: CronJobsStore = .shared) {
|
||||
init(store: CronJobsStore = .shared, channelsStore: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
self.channelsStore = channelsStore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ enum DebugActions {
|
||||
static func openLog() {
|
||||
let path = self.pinoLogPath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
guard FileManager().fileExists(atPath: path) else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Log file not found"
|
||||
alert.informativeText = path
|
||||
@@ -38,7 +38,7 @@ enum DebugActions {
|
||||
|
||||
@MainActor
|
||||
static func openConfigFolder() {
|
||||
let url = FileManager.default
|
||||
let url = FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
@@ -55,7 +55,7 @@ enum DebugActions {
|
||||
}
|
||||
let path = self.resolveSessionStorePath()
|
||||
let url = URL(fileURLWithPath: path)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
if FileManager().fileExists(atPath: path) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
} else {
|
||||
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
||||
@@ -195,7 +195,7 @@ enum DebugActions {
|
||||
@MainActor
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
||||
let configURL = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot/clawdbot.json")
|
||||
guard
|
||||
let data = try? Data(contentsOf: configURL),
|
||||
|
||||
@@ -16,6 +16,8 @@ struct DebugSettings: View {
|
||||
@State private var modelsError: String?
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let healthStore = HealthStore.shared
|
||||
@State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
@State private var launchAgentWriteError: String?
|
||||
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||
@State private var sessionStoreSaveError: String?
|
||||
@@ -47,6 +49,7 @@ struct DebugSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
|
||||
self.launchdSection
|
||||
self.appInfoSection
|
||||
self.gatewaySection
|
||||
self.logsSection
|
||||
@@ -79,6 +82,41 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var launchdSection: some View {
|
||||
GroupBox("Gateway startup") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
|
||||
.onChange(of: self.launchAgentWriteDisabled) { _, newValue in
|
||||
self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
|
||||
if self.launchAgentWriteError != nil {
|
||||
self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
|
||||
return
|
||||
}
|
||||
if newValue {
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
|
||||
"It will only attach to an existing Gateway.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let launchAgentWriteError {
|
||||
Text(launchAgentWriteError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
@@ -354,7 +392,7 @@ struct DebugSettings: View {
|
||||
Button("Save") { self.saveRelayRoot() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Reset") {
|
||||
let def = FileManager.default.homeDirectoryForCurrentUser
|
||||
let def = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdbot").path
|
||||
self.gatewayRootInput = def
|
||||
self.saveRelayRoot()
|
||||
@@ -484,6 +522,22 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||
@@ -743,7 +797,7 @@ struct DebugSettings: View {
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
@@ -776,7 +830,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
|
||||
}
|
||||
|
||||
nonisolated static func logDirectoryURL() -> URL {
|
||||
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||
return library
|
||||
.appendingPathComponent("Logs", isDirectory: true)
|
||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
|
||||
}
|
||||
|
||||
func clear() throws {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
let base = Self.logFileURL()
|
||||
if fm.fileExists(atPath: base.path) {
|
||||
try fm.removeItem(at: base)
|
||||
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
|
||||
}
|
||||
|
||||
private func ensureDirectory() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
try FileManager().createDirectory(
|
||||
at: Self.logDirectoryURL(),
|
||||
withIntermediateDirectories: true)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
|
||||
line.append(data)
|
||||
line.append(0x0A) // newline
|
||||
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
fm.createFile(atPath: url.path, contents: nil)
|
||||
}
|
||||
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
|
||||
|
||||
private func rotateIfNeeded() throws {
|
||||
let url = Self.logFileURL()
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
|
||||
let size = attrs[.size] as? NSNumber
|
||||
else { return }
|
||||
|
||||
if size.int64Value < self.maxBytes { return }
|
||||
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
|
||||
let oldest = self.rotatedURL(index: self.maxBackups)
|
||||
if fm.fileExists(atPath: oldest.path) {
|
||||
|
||||
@@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults {
|
||||
|
||||
enum ExecApprovalsStore {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||
private static let defaultAgentId = "main"
|
||||
private static let defaultSecurity: ExecSecurity = .deny
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
@@ -165,18 +166,27 @@ enum ExecApprovalsStore {
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
var agents = file.agents ?? [:]
|
||||
if let legacyDefault = agents["default"] {
|
||||
if let main = agents[self.defaultAgentId] {
|
||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
||||
} else {
|
||||
agents[self.defaultAgentId] = legacyDefault
|
||||
}
|
||||
agents.removeValue(forKey: "default")
|
||||
}
|
||||
return ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: file.agents)
|
||||
agents: agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
let url = self.fileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: false,
|
||||
@@ -216,7 +226,7 @@ enum ExecApprovalsStore {
|
||||
|
||||
static func loadFile() -> ExecApprovalsFile {
|
||||
let url = self.fileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
do {
|
||||
@@ -238,11 +248,11 @@ enum ExecApprovalsStore {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let url = self.fileURL()
|
||||
try FileManager.default.createDirectory(
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -272,16 +282,17 @@ enum ExecApprovalsStore {
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "default"
|
||||
let key = self.agentKey(agentId)
|
||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = (agentEntry.allowlist ?? [])
|
||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||
?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||
?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
@@ -442,11 +453,11 @@ enum ExecApprovalsStore {
|
||||
private static func expandPath(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == "~" {
|
||||
return FileManager.default.homeDirectoryForCurrentUser.path
|
||||
return FileManager().homeDirectoryForCurrentUser.path
|
||||
}
|
||||
if trimmed.hasPrefix("~/") {
|
||||
let suffix = trimmed.dropFirst(2)
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
return FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(String(suffix)).path
|
||||
}
|
||||
return trimmed
|
||||
@@ -454,7 +465,40 @@ enum ExecApprovalsStore {
|
||||
|
||||
private static func agentKey(_ agentId: String?) -> String {
|
||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "default" : trimmed
|
||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||
}
|
||||
|
||||
private static func mergeAgents(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent) -> ExecApprovalsAgent
|
||||
{
|
||||
var seen = Set<String>()
|
||||
var allowlist: [ExecAllowlistEntry] = []
|
||||
func append(_ entry: ExecAllowlistEntry) {
|
||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
||||
return
|
||||
}
|
||||
seen.insert(key)
|
||||
allowlist.append(entry)
|
||||
}
|
||||
for entry in current.allowlist ?? [] {
|
||||
append(entry)
|
||||
}
|
||||
for entry in legacy.allowlist ?? [] {
|
||||
append(entry)
|
||||
}
|
||||
|
||||
return ExecApprovalsAgent(
|
||||
security: current.security ?? legacy.security,
|
||||
ask: current.ask ?? legacy.ask,
|
||||
askFallback: current.askFallback ?? legacy.askFallback,
|
||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +541,7 @@ struct ExecCommandResolution: Sendable {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
let searchPaths = self.searchPaths(from: env)
|
||||
@@ -553,6 +597,30 @@ enum ExecCommandFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalHelpers {
|
||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return ExecApprovalDecision(rawValue: trimmed)
|
||||
}
|
||||
|
||||
static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -44,6 +45,7 @@ final class ExecApprovalsGatewayPrompter {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||
guard self.shouldPresent(request: request) else { return }
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||
try await GatewayConnection.shared.requestVoid(
|
||||
method: .execApprovalResolve,
|
||||
@@ -56,4 +58,66 @@ final class ExecApprovalsGatewayPrompter {
|
||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldPresent(request: GatewayApprovalRequest) -> Bool {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Self.shouldPresent(
|
||||
mode: mode,
|
||||
activeSession: activeSession,
|
||||
requestSession: requestSession,
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
thresholdSeconds: 120)
|
||||
}
|
||||
|
||||
private static func shouldPresent(
|
||||
mode: AppState.ConnectionMode,
|
||||
activeSession: String?,
|
||||
requestSession: String?,
|
||||
lastInputSeconds: Int?,
|
||||
thresholdSeconds: Int) -> Bool
|
||||
{
|
||||
let active = activeSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requested = requestSession?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let recentlyActive = lastInputSeconds.map { $0 <= thresholdSeconds } ?? (mode == .local)
|
||||
|
||||
if let session = requested, !session.isEmpty {
|
||||
if let active, !active.isEmpty {
|
||||
return active == session
|
||||
}
|
||||
return recentlyActive
|
||||
}
|
||||
|
||||
if let active, !active.isEmpty {
|
||||
return true
|
||||
}
|
||||
return mode == .local
|
||||
}
|
||||
|
||||
private static func lastInputSeconds() -> Int? {
|
||||
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
|
||||
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)
|
||||
if seconds.isNaN || seconds.isInfinite || seconds < 0 { return nil }
|
||||
return Int(seconds.rounded())
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension ExecApprovalsGatewayPrompter {
|
||||
static func _testShouldPresent(
|
||||
mode: AppState.ConnectionMode,
|
||||
activeSession: String?,
|
||||
requestSession: String?,
|
||||
lastInputSeconds: Int?,
|
||||
thresholdSeconds: Int = 120) -> Bool
|
||||
{
|
||||
self.shouldPresent(
|
||||
mode: mode,
|
||||
activeSession: activeSession,
|
||||
requestSession: requestSession,
|
||||
lastInputSeconds: lastInputSeconds,
|
||||
thresholdSeconds: thresholdSeconds)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||
var ask: String?
|
||||
var agentId: String?
|
||||
var resolvedPath: String?
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private struct ExecApprovalSocketRequest: Codable {
|
||||
@@ -46,6 +47,7 @@ private struct ExecHostRequest: Codable {
|
||||
var needsScreenRecording: Bool?
|
||||
var agentId: String?
|
||||
var sessionKey: String?
|
||||
var approvalDecision: ExecApprovalDecision?
|
||||
}
|
||||
|
||||
private struct ExecHostRunResult: Codable {
|
||||
@@ -214,36 +216,15 @@ enum ExecApprovalsPromptPresenter {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedAgent.isEmpty {
|
||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||
}
|
||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPath.isEmpty {
|
||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||
}
|
||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedHost.isEmpty {
|
||||
details += "\n\nHost:\n\(trimmedHost)"
|
||||
}
|
||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||
details += "\n\nSecurity:\n\(security)"
|
||||
}
|
||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||
details += "\nAsk mode:\n\(ask)"
|
||||
}
|
||||
details += "\n\nThis runs on this machine."
|
||||
alert.informativeText = details
|
||||
alert.informativeText = "Review the command details before allowing."
|
||||
alert.accessoryView = self.buildAccessoryView(request)
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
@@ -254,10 +235,128 @@ enum ExecApprovalsPromptPresenter {
|
||||
return .deny
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAccessoryView(_ request: ExecApprovalPromptRequest) -> NSView {
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
stack.alignment = .leading
|
||||
|
||||
let commandTitle = NSTextField(labelWithString: "Command")
|
||||
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
stack.addArrangedSubview(commandTitle)
|
||||
|
||||
let commandText = NSTextView()
|
||||
commandText.isEditable = false
|
||||
commandText.isSelectable = true
|
||||
commandText.drawsBackground = true
|
||||
commandText.backgroundColor = NSColor.textBackgroundColor
|
||||
commandText.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
commandText.string = request.command
|
||||
commandText.textContainerInset = NSSize(width: 6, height: 6)
|
||||
commandText.textContainer?.lineFragmentPadding = 0
|
||||
commandText.textContainer?.widthTracksTextView = true
|
||||
commandText.isHorizontallyResizable = false
|
||||
commandText.isVerticallyResizable = false
|
||||
|
||||
let commandScroll = NSScrollView()
|
||||
commandScroll.borderType = .lineBorder
|
||||
commandScroll.hasVerticalScroller = false
|
||||
commandScroll.hasHorizontalScroller = false
|
||||
commandScroll.documentView = commandText
|
||||
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
||||
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||
stack.addArrangedSubview(commandScroll)
|
||||
|
||||
let contextTitle = NSTextField(labelWithString: "Context")
|
||||
contextTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
stack.addArrangedSubview(contextTitle)
|
||||
|
||||
let contextStack = NSStackView()
|
||||
contextStack.orientation = .vertical
|
||||
contextStack.spacing = 4
|
||||
contextStack.alignment = .leading
|
||||
|
||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
self.addDetailRow(title: "Working directory", value: trimmedCwd, to: contextStack)
|
||||
}
|
||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedAgent.isEmpty {
|
||||
self.addDetailRow(title: "Agent", value: trimmedAgent, to: contextStack)
|
||||
}
|
||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPath.isEmpty {
|
||||
self.addDetailRow(title: "Executable", value: trimmedPath, to: contextStack)
|
||||
}
|
||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedHost.isEmpty {
|
||||
self.addDetailRow(title: "Host", value: trimmedHost, to: contextStack)
|
||||
}
|
||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||
self.addDetailRow(title: "Security", value: security, to: contextStack)
|
||||
}
|
||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||
self.addDetailRow(title: "Ask mode", value: ask, to: contextStack)
|
||||
}
|
||||
|
||||
if contextStack.arrangedSubviews.isEmpty {
|
||||
let empty = NSTextField(labelWithString: "No additional context provided.")
|
||||
empty.textColor = NSColor.secondaryLabelColor
|
||||
empty.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
contextStack.addArrangedSubview(empty)
|
||||
}
|
||||
|
||||
stack.addArrangedSubview(contextStack)
|
||||
|
||||
let footer = NSTextField(labelWithString: "This runs on this machine.")
|
||||
footer.textColor = NSColor.secondaryLabelColor
|
||||
footer.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
stack.addArrangedSubview(footer)
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func addDetailRow(title: String, value: String, to stack: NSStackView) {
|
||||
let row = NSStackView()
|
||||
row.orientation = .horizontal
|
||||
row.spacing = 6
|
||||
row.alignment = .firstBaseline
|
||||
|
||||
let titleLabel = NSTextField(labelWithString: "\(title):")
|
||||
titleLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold)
|
||||
titleLabel.textColor = NSColor.secondaryLabelColor
|
||||
|
||||
let valueLabel = NSTextField(labelWithString: value)
|
||||
valueLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
valueLabel.lineBreakMode = .byTruncatingMiddle
|
||||
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
row.addArrangedSubview(titleLabel)
|
||||
row.addArrangedSubview(valueLabel)
|
||||
stack.addArrangedSubview(row)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum ExecHostExecutor {
|
||||
private struct ExecApprovalContext {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let trimmedAgent: String?
|
||||
let approvals: ExecApprovalsResolved
|
||||
let security: ExecSecurity
|
||||
let ask: ExecAsk
|
||||
let autoAllowSkills: Bool
|
||||
let env: [String: String]?
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
let skillAllow: Bool
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
@@ -276,14 +375,94 @@ private enum ExecHostExecutor {
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !command.isEmpty else {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||
return self.errorResponse(
|
||||
code: "INVALID_REQUEST",
|
||||
message: "command required",
|
||||
reason: "invalid")
|
||||
}
|
||||
|
||||
let context = await self.buildContext(request: request, command: command)
|
||||
if context.security == .deny {
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||
reason: "security=deny")
|
||||
}
|
||||
|
||||
let approvalDecision = request.approvalDecision
|
||||
if approvalDecision == .deny {
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied")
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow),
|
||||
approvalDecision == nil
|
||||
{
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.trimmedAgent,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: request.sessionKey))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied")
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
self.persistAllowlistEntry(decision: decision, context: context)
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||
|
||||
if context.security == .allowlist,
|
||||
context.allowlistMatch == nil,
|
||||
!context.skillAllow,
|
||||
!approvedByAsk
|
||||
{
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||
reason: "allowlist-miss")
|
||||
}
|
||||
|
||||
if let match = context.allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: context.trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: context.displayCommand,
|
||||
resolvedPath: context.resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||
return errorResponse
|
||||
}
|
||||
|
||||
return await self.runCommand(
|
||||
command: command,
|
||||
cwd: request.cwd,
|
||||
env: context.env,
|
||||
timeoutMs: request.timeoutMs)
|
||||
}
|
||||
|
||||
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||
let displayCommand = ExecCommandFormatter.displayString(
|
||||
for: command,
|
||||
rawCommand: request.rawCommand)
|
||||
@@ -309,102 +488,56 @@ private enum ExecHostExecutor {
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
return ExecApprovalContext(
|
||||
command: command,
|
||||
displayCommand: displayCommand,
|
||||
trimmedAgent: trimmedAgent,
|
||||
approvals: approvals,
|
||||
security: security,
|
||||
ask: ask,
|
||||
autoAllowSkills: autoAllowSkills,
|
||||
env: env,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
if security == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||
reason: "security=deny"))
|
||||
private static func persistAllowlistEntry(
|
||||
decision: ExecApprovalDecision?,
|
||||
context: ExecApprovalContext)
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: context.resolution)
|
||||
else {
|
||||
return
|
||||
}
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if authorized { return nil }
|
||||
return self.errorResponse(
|
||||
code: "UNAVAILABLE",
|
||||
message: "PERMISSION_MISSING: screenRecording",
|
||||
reason: "permission:screenRecording")
|
||||
}
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: trimmedAgent,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: user denied",
|
||||
reason: "user-denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||
reason: "allowlist-miss"))
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if request.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(
|
||||
code: "UNAVAILABLE",
|
||||
message: "PERMISSION_MISSING: screenRecording",
|
||||
reason: "permission:screenRecording"))
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
private static func runCommand(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
timeoutMs: Int?) async -> ExecHostResponse
|
||||
{
|
||||
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||
await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: request.cwd,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
}.value
|
||||
@@ -415,7 +548,24 @@ private enum ExecHostExecutor {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
return ExecHostResponse(
|
||||
return self.successResponse(payload)
|
||||
}
|
||||
|
||||
private static func errorResponse(
|
||||
code: String,
|
||||
message: String,
|
||||
reason: String?) -> ExecHostResponse
|
||||
{
|
||||
ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: code, message: message, reason: reason))
|
||||
}
|
||||
|
||||
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||
ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
case bluebubbles
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
@@ -68,6 +69,7 @@ actor GatewayConnection {
|
||||
case channelsLogout = "channels.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
case sessionsPreview = "sessions.preview"
|
||||
case chatSend = "chat.send"
|
||||
case chatAbort = "chat.abort"
|
||||
case skillsStatus = "skills.status"
|
||||
@@ -147,6 +149,27 @@ actor GatewayConnection {
|
||||
}
|
||||
}
|
||||
|
||||
let nsError = lastError as NSError
|
||||
if nsError.domain == URLError.errorDomain,
|
||||
let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url)
|
||||
{
|
||||
await self.configure(url: fallback.url, token: fallback.token, password: fallback.password)
|
||||
for delayMs in [150, 400, 900] {
|
||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||
do {
|
||||
guard let client = self.client else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway not configured"])
|
||||
}
|
||||
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
case .remote:
|
||||
let nsError = error as NSError
|
||||
@@ -227,6 +250,11 @@ actor GatewayConnection {
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
}
|
||||
|
||||
func authSource() async -> GatewayAuthSource? {
|
||||
guard let client else { return nil }
|
||||
return await client.authSource()
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
@@ -243,9 +271,9 @@ actor GatewayConnection {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func sessionDefaultString(_ defaults: [String: AnyCodable]?, key: String) -> String {
|
||||
(defaults?[key]?.stringValue ?? "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
private func sessionDefaultString(_ defaults: [String: ClawdbotProtocol.AnyCodable]?, key: String) -> String {
|
||||
let raw = defaults?[key]?.value as? String
|
||||
return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func cachedMainSessionKey() -> String? {
|
||||
@@ -513,6 +541,30 @@ extension GatewayConnection {
|
||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func sessionsPreview(
|
||||
keys: [String],
|
||||
limit: Int? = nil,
|
||||
maxChars: Int? = nil,
|
||||
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
|
||||
{
|
||||
let resolvedKeys = keys
|
||||
.map { self.canonicalizeSessionKey($0) }
|
||||
.filter { !$0.isEmpty }
|
||||
if resolvedKeys.isEmpty {
|
||||
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
|
||||
}
|
||||
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
||||
if let limit { params["limit"] = AnyCodable(limit) }
|
||||
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||
let timeout = timeoutMs.map { Double($0) }
|
||||
return try await self.requestDecoded(
|
||||
method: .sessionsPreview,
|
||||
params: params,
|
||||
timeoutMs: timeout)
|
||||
}
|
||||
|
||||
// MARK: - Chat
|
||||
|
||||
func chatHistory(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var lastResolvedURL: URL?
|
||||
|
||||
private(set) var endpointState: GatewayEndpointState?
|
||||
private(set) var resolvedURL: URL?
|
||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||
private(set) var resolvedHostLabel: String?
|
||||
|
||||
private init() {
|
||||
self.start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.endpointTask == nil else { return }
|
||||
self.endpointTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||
for await state in stream {
|
||||
await MainActor.run { self.handleEndpointState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localEndpointHostLabel: String? {
|
||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||
return Self.hostLabel(for: url)
|
||||
}
|
||||
|
||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||
self.endpointState = state
|
||||
switch state {
|
||||
case let .ready(mode, url, _, _):
|
||||
self.resolvedMode = mode
|
||||
self.resolvedURL = url
|
||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||
if urlChanged {
|
||||
self.lastResolvedURL = url
|
||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||
}
|
||||
case let .connecting(mode, _):
|
||||
self.resolvedMode = mode
|
||||
case let .unavailable(mode, _):
|
||||
self.resolvedMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostLabel(for url: URL) -> String {
|
||||
let host = url.host ?? url.absoluteString
|
||||
if let port = url.port { return "\(host):\(port)" }
|
||||
return host
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -155,7 +156,8 @@ actor GatewayEndpointStore {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
!configToken.isEmpty,
|
||||
configToken != trimmed
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .token,
|
||||
@@ -164,32 +166,23 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
return configToken
|
||||
}
|
||||
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return token
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -481,6 +474,36 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
|
||||
func maybeFallbackToTailnet(from currentURL: URL) async -> GatewayConnection.Config? {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .local else { return nil }
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
guard bind == "tailnet" else { return nil }
|
||||
|
||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let port = self.deps.localPort()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "\(scheme)://\(tailscaleIP):\(port)")!
|
||||
|
||||
self.logger.info("auto bind fallback to tailnet host=\(tailscaleIP, privacy: .public)")
|
||||
self.setState(.ready(mode: .local, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
}
|
||||
|
||||
private static func resolveGatewayBindMode(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
@@ -536,8 +559,10 @@ actor GatewayEndpointStore {
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet", "auto":
|
||||
case "tailnet":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
"127.0.0.1"
|
||||
case "custom":
|
||||
customBindHost ?? "127.0.0.1"
|
||||
default:
|
||||
@@ -612,11 +637,12 @@ extension GatewayEndpointStore {
|
||||
|
||||
static func _testResolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
tailscaleIP: String?,
|
||||
customBindHost: String? = nil) -> String
|
||||
{
|
||||
self.resolveLocalGatewayHost(
|
||||
bindMode: bindMode,
|
||||
customBindHost: nil,
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,46 @@ enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var disableLaunchAgentMarkerURL: URL {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
||||
}
|
||||
|
||||
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
||||
let marker = self.disableLaunchAgentMarkerURL
|
||||
if disabled {
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: marker.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: marker.path) {
|
||||
FileManager().createFile(atPath: marker.path, contents: nil)
|
||||
}
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if FileManager().fileExists(atPath: marker.path) {
|
||||
do {
|
||||
try FileManager().removeItem(at: marker)
|
||||
} catch {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||
return loaded
|
||||
@@ -66,12 +101,6 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
let marker = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager.default.fileExists(atPath: marker.path)
|
||||
}
|
||||
|
||||
private static func readDaemonLoaded() async -> Bool? {
|
||||
let result = await self.runDaemonCommandResult(
|
||||
["status", "--json", "--no-probe"],
|
||||
@@ -115,7 +144,7 @@ extension GatewayLaunchAgentManager {
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
subcommand: "gateway",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
@@ -42,10 +42,20 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
#if DEBUG
|
||||
private var testingConnection: GatewayConnection?
|
||||
#endif
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||
|
||||
private let logLimit = 20000 // characters to keep in-memory
|
||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||
private var connection: GatewayConnection {
|
||||
#if DEBUG
|
||||
return self.testingConnection ?? .shared
|
||||
#else
|
||||
return .shared
|
||||
#endif
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
@@ -69,6 +79,11 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -126,6 +141,10 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
func clearLastFailure() {
|
||||
self.lastFailureReason = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force {
|
||||
@@ -178,7 +197,7 @@ final class GatewayProcessManager {
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
@@ -187,6 +206,7 @@ final class GatewayProcessManager {
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.clearLastFailure()
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.logger.info("gateway using existing instance details=\(details)")
|
||||
@@ -222,19 +242,17 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linkId = snap.channelOrder?.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.channels.keys.first(where: {
|
||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||
guard let linkId else {
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
let linked = snap.channels[linkId]?.linked ?? false
|
||||
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"channel"
|
||||
snap.channelLabels?[linkId] ??
|
||||
linkId.capitalized
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
@@ -293,6 +311,15 @@ final class GatewayProcessManager {
|
||||
return
|
||||
}
|
||||
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
let message = "Launchd disabled; start the Gateway manually or disable attach-only."
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = "launchd disabled"
|
||||
self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
|
||||
self.logger.info("gateway launchd enable skipped (disable marker set)")
|
||||
return
|
||||
}
|
||||
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||
@@ -310,9 +337,10 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
@@ -352,7 +380,8 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
return true
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -365,7 +394,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func clearLog() {
|
||||
self.log = ""
|
||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||
self.logger.debug("gateway log cleared")
|
||||
}
|
||||
|
||||
@@ -378,10 +407,26 @@ final class GatewayProcessManager {
|
||||
}
|
||||
|
||||
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
||||
guard FileManager.default.fileExists(atPath: path) else { return "" }
|
||||
guard FileManager().fileExists(atPath: path) else { return "" }
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
if text.count <= limit { return text }
|
||||
return String(text.suffix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayProcessManager {
|
||||
func setTestingConnection(_ connection: GatewayConnection?) {
|
||||
self.testingConnection = connection
|
||||
}
|
||||
|
||||
func setTestingDesiredActive(_ active: Bool) {
|
||||
self.desiredActive = active
|
||||
}
|
||||
|
||||
func setTestingLastFailureReason(_ reason: String?) {
|
||||
self.lastFailureReason = reason
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,52 +2,25 @@ import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "Clawdbot active",
|
||||
@@ -83,29 +56,6 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SystemRunSettingsView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
@@ -130,29 +80,13 @@ struct GeneralSettings: View {
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
@@ -161,39 +95,20 @@ struct GeneralSettings: View {
|
||||
set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var locationMode: ClawdbotLocationMode {
|
||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
return false
|
||||
}
|
||||
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdbot runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("", selection: self.$state.connectionMode) {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 380, alignment: .leading)
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .leading)
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
@@ -216,8 +131,6 @@ struct GeneralSettings: View {
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.cliInstaller
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +212,11 @@ struct GeneralSettings: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
Text(authLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
@@ -346,59 +264,6 @@ struct GeneralSettings: View {
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var cliInstaller: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
if self.isInstallingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
}
|
||||
.disabled(self.isInstallingCLI)
|
||||
|
||||
if self.isInstallingCLI {
|
||||
Text("Working...")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.cliInstalled {
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not installed")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let status = cliStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let installLocation = self.cliInstallLocation {
|
||||
Text("Found at \(installLocation)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -454,22 +319,6 @@ struct GeneralSettings: View {
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.isInstallingCLI else { return }
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshGatewayStatus() {
|
||||
Task {
|
||||
let status = await Task.detached(priority: .utility) {
|
||||
@@ -763,9 +612,6 @@ extension GeneralSettings {
|
||||
message: "Gateway ready")
|
||||
view.remoteStatus = .failed("SSH failed")
|
||||
view.showRemoteAdvanced = true
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
||||
view.cliStatus = "Installed"
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .unconfigured
|
||||
|
||||
@@ -166,6 +166,11 @@ final class HealthStore {
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||
{
|
||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked == true {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
for id in order {
|
||||
if let summary = snap.channels[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
@@ -235,8 +240,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -3,17 +3,17 @@ import Foundation
|
||||
enum LaunchAgentManager {
|
||||
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
static func status() async -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
|
||||
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
|
||||
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||
return result == 0
|
||||
}
|
||||
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
try? FileManager().removeItem(at: self.legacyPlistURL)
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
|
||||
} else {
|
||||
// Disable autostart going forward but leave the current app running.
|
||||
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
|
||||
try? FileManager.default.removeItem(at: self.plistURL)
|
||||
try? FileManager().removeItem(at: self.plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ enum LaunchAgentManager {
|
||||
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
||||
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
|
||||
@@ -18,7 +18,7 @@ enum LogLocator {
|
||||
}
|
||||
|
||||
private static func ensureLogDirExists() {
|
||||
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
|
||||
try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private static func modificationDate(for url: URL) -> Date {
|
||||
@@ -28,7 +28,7 @@ enum LogLocator {
|
||||
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
||||
static func bestLogFile() -> URL? {
|
||||
self.ensureLogDirExists()
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
let files = (try? fm.contentsOfDirectory(
|
||||
at: self.logDir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
|
||||
@@ -3,6 +3,7 @@ import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@@ -10,9 +11,11 @@ import SwiftUI
|
||||
struct ClawdbotApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||
@State private var state: AppState
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@@ -30,6 +33,7 @@ struct ClawdbotApp: App {
|
||||
|
||||
init() {
|
||||
ClawdbotLogging.bootstrapIfNeeded()
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
@@ -90,6 +94,22 @@ struct ClawdbotApp: App {
|
||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
let args = CommandLine.arguments
|
||||
guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
|
||||
if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
|
||||
Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: Bundle.main.bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
}
|
||||
Self.logger.info("attach-only flag enabled")
|
||||
}
|
||||
|
||||
private var isGatewaySleeping: Bool {
|
||||
if self.state.isPaused { return false }
|
||||
switch self.state.connectionMode {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@@ -18,6 +19,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
private var isObservingControlChannel = false
|
||||
|
||||
private var cachedSnapshot: SessionStoreSnapshot?
|
||||
private var cachedErrorText: String?
|
||||
@@ -50,6 +52,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||
}
|
||||
|
||||
self.startControlChannelObservation()
|
||||
self.nodesStore.start()
|
||||
}
|
||||
|
||||
@@ -96,6 +99,50 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
private func startControlChannelObservation() {
|
||||
guard !self.isObservingControlChannel else { return }
|
||||
self.isObservingControlChannel = true
|
||||
self.observeControlChannelState()
|
||||
}
|
||||
|
||||
private func observeControlChannelState() {
|
||||
withObservationTracking {
|
||||
_ = ControlChannel.shared.state
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleControlChannelStateChange()
|
||||
self.observeControlChannelState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleControlChannelStateChange() {
|
||||
guard self.isMenuOpen, let menu = self.statusItem?.menu else { return }
|
||||
self.loadTask?.cancel()
|
||||
self.loadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
await self.refreshCache(force: true)
|
||||
await self.refreshUsageCache(force: true)
|
||||
await self.refreshCostUsageCache(force: true)
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.inject(into: menu)
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.nodesLoadTask = Task { [weak self, weak menu] in
|
||||
guard let self, let menu else { return }
|
||||
await self.nodesStore.refresh()
|
||||
await MainActor.run {
|
||||
guard self.isMenuOpen else { return }
|
||||
self.injectNodes(into: menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||
}
|
||||
@@ -141,14 +188,23 @@ extension MenuSessionsInjector {
|
||||
if rhs.key == mainKey { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
if !rows.isEmpty {
|
||||
let previewKeys = rows.prefix(20).map(\.key)
|
||||
let task = Task {
|
||||
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
}
|
||||
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = self
|
||||
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
statusText: statusText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -469,7 +525,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
@@ -598,8 +654,11 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = nil
|
||||
if self.cachedSnapshot != nil {
|
||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||
} else {
|
||||
self.cachedErrorText = nil
|
||||
}
|
||||
self.cacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -624,8 +683,6 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -648,8 +705,6 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,14 +2,28 @@ import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
enum ModelCatalogLoader {
|
||||
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||
static var defaultPath: String { self.resolveDefaultPath() }
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
}()
|
||||
|
||||
private static var cachePath: URL {
|
||||
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
||||
}
|
||||
|
||||
static func load(from path: String) async throws -> [ModelChoice] {
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
||||
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||
self.logger.error("model catalog load failed: file not found")
|
||||
throw NSError(
|
||||
domain: "ModelCatalogLoader",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
||||
}
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
||||
let sanitized = self.sanitize(source: source)
|
||||
|
||||
let ctx = JSContext()
|
||||
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
|
||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||
}
|
||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||
if resolved.shouldCache {
|
||||
self.cacheCatalog(sourcePath: resolved.path)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
private static func resolveDefaultPath() -> String {
|
||||
let cache = self.cachePath.path
|
||||
if FileManager().isReadableFile(atPath: cache) { return cache }
|
||||
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
||||
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
||||
return cache
|
||||
}
|
||||
|
||||
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
||||
if FileManager().isReadableFile(atPath: preferred) {
|
||||
return (preferred, preferred != self.cachePath.path)
|
||||
}
|
||||
|
||||
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
||||
return (bundlePath, true)
|
||||
}
|
||||
|
||||
let cache = self.cachePath.path
|
||||
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
||||
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
||||
return (cache, false)
|
||||
}
|
||||
|
||||
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
||||
return (nodePath, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func bundleCatalogPath() -> String? {
|
||||
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
||||
return nil
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
private static func nodeModulesCatalogPath() -> String? {
|
||||
let roots = [
|
||||
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
||||
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
||||
]
|
||||
for root in roots {
|
||||
let candidate = root
|
||||
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
||||
if FileManager().isReadableFile(atPath: candidate.path) {
|
||||
return candidate.path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func cacheCatalog(sourcePath: String) {
|
||||
let destination = self.cachePath
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if FileManager().fileExists(atPath: destination.path) {
|
||||
try FileManager().removeItem(at: destination)
|
||||
}
|
||||
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
||||
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
||||
} catch {
|
||||
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitize(source: String) -> String {
|
||||
guard let exportRange = source.range(of: "export const MODELS"),
|
||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||
|
||||
@@ -133,7 +133,7 @@ actor MacNodeRuntime {
|
||||
|
||||
let sessionKey = self.mainSessionKey
|
||||
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
||||
defer { try? FileManager().removeItem(atPath: path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
guard let image = NSImage(data: data) else {
|
||||
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
||||
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
|
||||
includeAudio: params.includeAudio ?? true,
|
||||
deviceId: params.deviceId,
|
||||
outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
||||
defer { try? FileManager().removeItem(atPath: res.path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||
struct ClipPayload: Encodable {
|
||||
var format: String
|
||||
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
|
||||
fps: params.fps,
|
||||
includeAudio: params.includeAudio,
|
||||
outPath: nil)
|
||||
defer { try? FileManager.default.removeItem(atPath: res.path) }
|
||||
defer { try? FileManager().removeItem(atPath: res.path) }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||
struct ScreenPayload: Encodable {
|
||||
var format: String
|
||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||
}
|
||||
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
let approvedByAsk = params.approved == true
|
||||
if requiresAsk, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
let approval = await self.resolveSystemRunApproval(
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: displayCommand,
|
||||
security: security,
|
||||
ask: ask,
|
||||
agentId: agentId,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response { return response }
|
||||
let approvedByAsk = approval.approvedByAsk
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
if persistAllowlist, security == .allowlist,
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||
{
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
@@ -619,6 +619,100 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private struct ExecApprovalOutcome {
|
||||
var approvedByAsk: Bool
|
||||
var persistAllowlist: Bool
|
||||
var response: BridgeInvokeResponse?
|
||||
}
|
||||
|
||||
private struct ExecRunContext {
|
||||
var displayCommand: String
|
||||
var security: ExecSecurity
|
||||
var ask: ExecAsk
|
||||
var agentId: String?
|
||||
var resolution: ExecCommandResolution?
|
||||
var allowlistMatch: ExecAllowlistEntry?
|
||||
var skillAllow: Bool
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private func resolveSystemRunApproval(
|
||||
req: BridgeInvokeRequest,
|
||||
params: ClawdbotSystemRunParams,
|
||||
context: ExecRunContext) async -> ExecApprovalOutcome
|
||||
{
|
||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow)
|
||||
|
||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||
var persistAllowlist = decisionFromParams == .allowAlways
|
||||
if decisionFromParams == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
}
|
||||
|
||||
if requiresAsk, !approvedByAsk {
|
||||
let decision = await MainActor.run {
|
||||
ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath,
|
||||
sessionKey: context.sessionKey))
|
||||
}
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
persistAllowlist = true
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: nil)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
@@ -217,7 +217,7 @@ final class OnboardingWizardModel {
|
||||
struct OnboardingWizardStepView: View {
|
||||
let step: WizardStep
|
||||
let isSubmitting: Bool
|
||||
let onSubmit: (AnyCodable?) -> Void
|
||||
let onStepSubmit: (AnyCodable?) -> Void
|
||||
|
||||
@State private var textValue: String
|
||||
@State private var confirmValue: Bool
|
||||
@@ -229,7 +229,7 @@ struct OnboardingWizardStepView: View {
|
||||
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
|
||||
self.step = step
|
||||
self.isSubmitting = isSubmitting
|
||||
self.onSubmit = onSubmit
|
||||
self.onStepSubmit = onSubmit
|
||||
let options = parseWizardOptions(step.options).enumerated().map { index, option in
|
||||
WizardOptionItem(index: index, option: option)
|
||||
}
|
||||
@@ -379,27 +379,27 @@ struct OnboardingWizardStepView: View {
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
case "text":
|
||||
self.onSubmit(AnyCodable(self.textValue))
|
||||
self.onStepSubmit(AnyCodable(self.textValue))
|
||||
case "confirm":
|
||||
self.onSubmit(AnyCodable(self.confirmValue))
|
||||
self.onStepSubmit(AnyCodable(self.confirmValue))
|
||||
case "select":
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
return
|
||||
}
|
||||
let option = self.optionItems[self.selectedIndex].option
|
||||
self.onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
case "multiselect":
|
||||
let values = self.optionItems
|
||||
.filter { self.selectedIndices.contains($0.index) }
|
||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||
self.onSubmit(AnyCodable(values))
|
||||
self.onStepSubmit(AnyCodable(values))
|
||||
case "action":
|
||||
self.onSubmit(AnyCodable(true))
|
||||
self.onStepSubmit(AnyCodable(true))
|
||||
default:
|
||||
self.onSubmit(nil)
|
||||
self.onStepSubmit(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSettings: View {
|
||||
@@ -8,6 +10,8 @@ struct PermissionsSettings: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SystemRunSettingsView()
|
||||
|
||||
Text("Allow these so Clawdbot can notify and capture when needed.")
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -15,6 +19,8 @@ struct PermissionsSettings: View {
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
LocationAccessSettings()
|
||||
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
@@ -24,6 +30,72 @@ struct PermissionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationAccessSettings: View {
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.onAppear {
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var locationMode: ClawdbotLocationMode {
|
||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
return false
|
||||
}
|
||||
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionStatusList: View {
|
||||
let status: [Capability: Bool]
|
||||
let refresh: () async -> Void
|
||||
@@ -45,25 +117,6 @@ struct PermissionStatusList: View {
|
||||
.font(.footnote)
|
||||
.padding(.top, 2)
|
||||
.help("Refresh status")
|
||||
|
||||
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ actor PortGuardian {
|
||||
private var records: [Record] = []
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
}()
|
||||
|
||||
@@ -71,7 +71,7 @@ actor PortGuardian {
|
||||
}
|
||||
|
||||
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
||||
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||
self.records.removeAll { $0.pid == pid }
|
||||
self.records.append(
|
||||
Record(
|
||||
@@ -184,6 +184,14 @@ actor PortGuardian {
|
||||
}
|
||||
}
|
||||
|
||||
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
||||
let listeners = await self.listeners(on: port)
|
||||
if let pid {
|
||||
return listeners.contains(where: { $0.pid == pid })
|
||||
}
|
||||
return !listeners.isEmpty
|
||||
}
|
||||
|
||||
private func listeners(on port: Int) async -> [Listener] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
|
||||
@@ -72,7 +72,6 @@ final class RemotePortTunnel {
|
||||
}
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
@@ -84,7 +83,12 @@ final class RemotePortTunnel {
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
if await self.isTunnelHealthy(port: local) {
|
||||
let pid = tunnel.process.processIdentifier
|
||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||
return local
|
||||
}
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
self.logger.error(
|
||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
@@ -35,19 +37,11 @@ actor RemoteTunnelManager {
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
if await self.isTunnelHealthy(port: desiredPort) {
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||
return nil
|
||||
}
|
||||
await self.beginRestart()
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -88,10 +82,6 @@ actor RemoteTunnelManager {
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
|
||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||
}
|
||||
|
||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||
let cmd = desc.command.lowercased()
|
||||
if cmd.contains("ssh") { return true }
|
||||
@@ -128,21 +118,5 @@ actor RemoteTunnelManager {
|
||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
}
|
||||
|
||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||
let pid = desc.pid
|
||||
self.logger.error(
|
||||
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
||||
let killed = await self.kill(pid: pid)
|
||||
if !killed {
|
||||
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
||||
}
|
||||
await PortGuardian.shared.removeRecord(pid: pid)
|
||||
}
|
||||
|
||||
private func kill(pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
return sigkill.ok
|
||||
}
|
||||
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.11-4</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601113</string>
|
||||
<string>202601210</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -111,7 +111,7 @@ enum RuntimeLocator {
|
||||
// MARK: - Internals
|
||||
|
||||
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
||||
let fm = FileManager.default
|
||||
let fm = FileManager()
|
||||
for dir in searchPaths {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
if fm.isExecutableFile(atPath: candidate) {
|
||||
|
||||
@@ -42,10 +42,10 @@ final class ScreenRecordService {
|
||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return URL(fileURLWithPath: outPath)
|
||||
}
|
||||
return FileManager.default.temporaryDirectory
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
try? FileManager.default.removeItem(at: outURL)
|
||||
try? FileManager().removeItem(at: outURL)
|
||||
|
||||
let content = try await SCShareableContent.current
|
||||
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
||||
|
||||
@@ -66,12 +66,12 @@ enum SessionActions {
|
||||
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
||||
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
||||
}
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
|
||||
return urls
|
||||
}()
|
||||
|
||||
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
|
||||
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
|
||||
guard let url = existing else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Session log not found"
|
||||
|
||||
@@ -246,7 +246,7 @@ enum SessionLoader {
|
||||
static let fallbackContextTokens = 200_000
|
||||
|
||||
static let defaultStorePath = standardize(
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
|
||||
|
||||
static func loadSnapshot(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
@@ -31,31 +32,80 @@ actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
let items: [SessionPreviewItem]
|
||||
let snapshot: SessionMenuPreviewSnapshot
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
private var entries: [String: CacheEntry] = [:]
|
||||
|
||||
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
||||
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
||||
guard let entry = self.entries[sessionKey] else { return nil }
|
||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||
return entry.items
|
||||
return entry.snapshot
|
||||
}
|
||||
|
||||
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
||||
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||
}
|
||||
|
||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
||||
self.entries[sessionKey]?.items
|
||||
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||
self.entries[sessionKey]?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
actor SessionPreviewLimiter {
|
||||
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
||||
|
||||
private let maxConcurrent: Int
|
||||
private var available: Int
|
||||
private var waitQueue: [UUID] = []
|
||||
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||
|
||||
init(maxConcurrent: Int) {
|
||||
let normalized = max(1, maxConcurrent)
|
||||
self.maxConcurrent = normalized
|
||||
self.available = normalized
|
||||
}
|
||||
|
||||
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
||||
await self.acquire()
|
||||
defer { self.release() }
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
private func acquire() async {
|
||||
if self.available > 0 {
|
||||
self.available -= 1
|
||||
return
|
||||
}
|
||||
let id = UUID()
|
||||
await withCheckedContinuation { cont in
|
||||
self.waitQueue.append(id)
|
||||
self.waiters[id] = cont
|
||||
}
|
||||
}
|
||||
|
||||
private func release() {
|
||||
if let id = self.waitQueue.first {
|
||||
self.waitQueue.removeFirst()
|
||||
if let cont = self.waiters.removeValue(forKey: id) {
|
||||
cont.resume()
|
||||
}
|
||||
return
|
||||
}
|
||||
self.available = min(self.available + 1, self.maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
func _testSet(
|
||||
snapshot: SessionMenuPreviewSnapshot,
|
||||
for sessionKey: String,
|
||||
updatedAt: Date = Date())
|
||||
{
|
||||
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
func _testReset() {
|
||||
@@ -174,36 +224,44 @@ enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
private static let previewMaxChars = 240
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
||||
let keys = self.uniqueKeys(sessionKeys)
|
||||
guard !keys.isEmpty else { return }
|
||||
do {
|
||||
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
||||
await self.cache(payload: payload, maxItems: maxItems)
|
||||
} catch {
|
||||
if self.isUnknownMethodError(error) { return }
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.debug(
|
||||
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
||||
"error=\(errorDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return self.snapshot(from: cached)
|
||||
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||
for: sessionKey,
|
||||
maxAge: cacheMaxAgeSeconds)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||
return snapshot
|
||||
} catch is CancellationError {
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||
return fallback
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
@@ -213,18 +271,120 @@ enum SessionMenuPreviewLoader {
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
||||
do {
|
||||
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
||||
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
||||
return self.snapshot(from: entry, maxItems: maxItems)
|
||||
}
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||
} catch {
|
||||
if self.isUnknownMethodError(error) {
|
||||
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestPreview(
|
||||
keys: [String],
|
||||
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
|
||||
{
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
return try await SessionPreviewLimiter.shared.withPermit {
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.sessionsPreview(
|
||||
keys: keys,
|
||||
limit: boundedItems,
|
||||
maxChars: self.previewMaxChars,
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchHistorySnapshot(
|
||||
sessionKey: String,
|
||||
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
||||
{
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
return Self.snapshot(from: built)
|
||||
}
|
||||
|
||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
}
|
||||
|
||||
private static func snapshot(
|
||||
from entry: ClawdbotSessionPreviewEntry,
|
||||
maxItems: Int) -> SessionMenuPreviewSnapshot
|
||||
{
|
||||
let items = self.previewItems(from: entry, maxItems: maxItems)
|
||||
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
switch normalized {
|
||||
case "ok":
|
||||
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
case "empty":
|
||||
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
||||
case "missing":
|
||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
||||
default:
|
||||
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
|
||||
for entry in payload.previews {
|
||||
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
||||
}
|
||||
}
|
||||
|
||||
private static func previewLimit(for maxItems: Int) -> Int {
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
return min(max(boundedItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
||||
max(1, min(maxItems, 50))
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from entry: ClawdbotSessionPreviewEntry,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
{
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
||||
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
let role = self.previewRoleFromRaw(item.role)
|
||||
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
||||
}
|
||||
|
||||
let trimmed = built.suffix(boundedItems)
|
||||
return Array(trimmed.reversed())
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
{
|
||||
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||
let messages = self.decodeMessages(raw)
|
||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||
@@ -235,7 +395,7 @@ enum SessionMenuPreviewLoader {
|
||||
return SessionPreviewItem(id: id, role: role, text: text)
|
||||
}
|
||||
|
||||
let trimmed = built.suffix(maxItems)
|
||||
let trimmed = built.suffix(boundedItems)
|
||||
return Array(trimmed.reversed())
|
||||
}
|
||||
|
||||
@@ -248,12 +408,16 @@ enum SessionMenuPreviewLoader {
|
||||
|
||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||
if isTool { return .tool }
|
||||
return self.previewRoleFromRaw(raw)
|
||||
}
|
||||
|
||||
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||
switch raw.lowercased() {
|
||||
case "user": return .user
|
||||
case "assistant": return .assistant
|
||||
case "system": return .system
|
||||
case "tool": return .tool
|
||||
default: return .other
|
||||
case "user": .user
|
||||
case "assistant": .assistant
|
||||
case "system": .system
|
||||
case "tool": .tool
|
||||
default: .other
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,4 +480,16 @@ enum SessionMenuPreviewLoader {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
||||
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
||||
}
|
||||
|
||||
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
||||
guard let response = error as? GatewayResponseError else { return false }
|
||||
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||
let message = response.message.lowercased()
|
||||
return message.contains("unknown method")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
|
||||
]
|
||||
|
||||
private static let searchRoots: [URL] = [
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
||||
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
||||
URL(fileURLWithPath: "/Library/Sounds"),
|
||||
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
|
||||
URL(fileURLWithPath: "/System/Library/Sounds"),
|
||||
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
|
||||
private static let discoveredSoundMap: [String: URL] = {
|
||||
var map: [String: URL] = [:]
|
||||
for root in Self.searchRoots {
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
guard let contents = try? FileManager().contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles])
|
||||
|
||||
@@ -2,6 +2,9 @@ import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
/// Manages Tailscale integration and status checking.
|
||||
@Observable
|
||||
@@ -53,7 +56,7 @@ final class TailscaleService {
|
||||
#endif
|
||||
|
||||
func checkAppInstallation() -> Bool {
|
||||
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
|
||||
let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app")
|
||||
self.logger.info("Tailscale app installed: \(installed)")
|
||||
return installed
|
||||
}
|
||||
@@ -100,16 +103,14 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
guard self.isInstalled else {
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
self.tailscaleHostname = nil
|
||||
self.tailscaleIP = nil
|
||||
self.statusError = "Tailscale is not installed"
|
||||
return
|
||||
}
|
||||
|
||||
if let apiResponse = await fetchTailscaleStatus() {
|
||||
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||
|
||||
if self.isRunning {
|
||||
@@ -138,6 +139,19 @@ final class TailscaleService {
|
||||
self.statusError = "Please start the Tailscale app"
|
||||
self.logger.info("Tailscale API not responding; app likely not running")
|
||||
}
|
||||
|
||||
if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() {
|
||||
self.tailscaleIP = fallback
|
||||
if !self.isRunning {
|
||||
self.isRunning = true
|
||||
}
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -163,4 +177,50 @@ final class TailscaleService {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if Self.isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
assert(Thread.isMainThread)
|
||||
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
||||
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func stopMonitoring() {
|
||||
assert(Thread.isMainThread)
|
||||
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||
if let globalMonitor {
|
||||
NSEvent.removeMonitor(globalMonitor)
|
||||
self.globalMonitor = nil
|
||||
@@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
|
||||
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
assert(Thread.isMainThread)
|
||||
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||
if keyCode == 61 {
|
||||
self.optionDown = modifierFlags.contains(.option)
|
||||
|
||||
@@ -52,7 +52,7 @@ enum WideAreaGatewayDiscovery {
|
||||
|
||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
@@ -66,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let suffix = "._clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
@@ -156,7 +156,7 @@ enum WideAreaGatewayDiscovery {
|
||||
{
|
||||
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||
let probeName = "_clawdbot-gw._tcp.\(domainTrimmed)"
|
||||
|
||||
let ips = candidates
|
||||
candidates.removeAll(keepingCapacity: true)
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotDiscoveryCLI {
|
||||
static func main() async {
|
||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac-discovery
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,8 +408,7 @@ extension Request: Codable {
|
||||
}
|
||||
|
||||
// Shared transport settings
|
||||
public let controlSocketPath =
|
||||
FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
public let controlSocketPath = FileManager()
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||
.path
|
||||
|
||||
359
apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift
Normal file
359
apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift
Normal file
@@ -0,0 +1,359 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
struct ConnectOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var mode: String?
|
||||
var timeoutMs: Int = 15000
|
||||
var json: Bool = false
|
||||
var probe: Bool = false
|
||||
var clientId: String = "clawdbot-macos"
|
||||
var clientMode: String = "ui"
|
||||
var displayName: String?
|
||||
var role: String = "operator"
|
||||
var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> ConnectOptions {
|
||||
var opts = ConnectOptions()
|
||||
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
|
||||
"-h": { $0.help = true },
|
||||
"--help": { $0.help = true },
|
||||
"--json": { $0.json = true },
|
||||
"--probe": { $0.probe = true },
|
||||
]
|
||||
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
|
||||
"--url": { $0.url = $1 },
|
||||
"--token": { $0.token = $1 },
|
||||
"--password": { $0.password = $1 },
|
||||
"--mode": { $0.mode = $1 },
|
||||
"--timeout": { opts, raw in
|
||||
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(250, parsed)
|
||||
}
|
||||
},
|
||||
"--client-id": { $0.clientId = $1 },
|
||||
"--client-mode": { $0.clientMode = $1 },
|
||||
"--display-name": { $0.displayName = $1 },
|
||||
"--role": { $0.role = $1 },
|
||||
"--scopes": { opts, raw in
|
||||
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
},
|
||||
]
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
if let handler = flagHandlers[arg] {
|
||||
handler(&opts)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
|
||||
handler(&opts, value)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
private static func nextValue(_ args: [String], index: inout Int) -> String? {
|
||||
guard index + 1 < args.count else { return nil }
|
||||
index += 1
|
||||
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectOutput: Encodable {
|
||||
var status: String
|
||||
var url: String
|
||||
var mode: String
|
||||
var role: String
|
||||
var clientId: String
|
||||
var clientMode: String
|
||||
var scopes: [String]
|
||||
var snapshot: HelloOk?
|
||||
var health: ProtoAnyCodable?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
actor SnapshotStore {
|
||||
private var value: HelloOk?
|
||||
|
||||
func set(_ snapshot: HelloOk) {
|
||||
self.value = snapshot
|
||||
}
|
||||
|
||||
func get() -> HelloOk? {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
func runConnect(_ args: [String]) async {
|
||||
let opts = ConnectOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac connect
|
||||
|
||||
Usage:
|
||||
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||
[--role <role>] [--scopes <a,b,c>]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Resolve from config: local|remote (default: config or local)
|
||||
--timeout <ms> Request timeout (default: 15000)
|
||||
--probe Force a fresh health probe
|
||||
--json Emit JSON
|
||||
--client-id <id> Override client id (default: clawdbot-macos)
|
||||
--client-mode <m> Override client mode (default: ui)
|
||||
--display-name <n> Override display name
|
||||
--role <role> Override role (default: operator)
|
||||
--scopes <a,b,c> Override scopes list
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||
let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI"
|
||||
let connectOptions = GatewayConnectOptions(
|
||||
role: opts.role,
|
||||
scopes: opts.scopes,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
clientDisplayName: displayName)
|
||||
|
||||
let snapshotStore = SnapshotStore()
|
||||
let channel = GatewayChannelActor(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
pushHandler: { push in
|
||||
if case let .snapshot(ok) = push {
|
||||
await snapshotStore.set(ok)
|
||||
}
|
||||
},
|
||||
connectOptions: connectOptions)
|
||||
|
||||
let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil
|
||||
let data = try await channel.request(
|
||||
method: "health",
|
||||
params: params,
|
||||
timeoutMs: Double(opts.timeoutMs))
|
||||
let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data)
|
||||
let snapshot = await snapshotStore.get()
|
||||
await channel.shutdown()
|
||||
|
||||
let output = ConnectOutput(
|
||||
status: "ok",
|
||||
url: endpoint.url.absoluteString,
|
||||
mode: endpoint.mode,
|
||||
role: opts.role,
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
scopes: opts.scopes,
|
||||
snapshot: snapshot,
|
||||
health: health,
|
||||
error: nil)
|
||||
printConnectOutput(output, json: opts.json)
|
||||
} catch {
|
||||
let endpoint = bestEffortEndpoint(opts: opts, config: config)
|
||||
let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||
let output = ConnectOutput(
|
||||
status: "error",
|
||||
url: endpoint?.url.absoluteString ?? "unknown",
|
||||
mode: endpoint?.mode ?? fallbackMode,
|
||||
role: opts.role,
|
||||
clientId: opts.clientId,
|
||||
clientMode: opts.clientMode,
|
||||
scopes: opts.scopes,
|
||||
snapshot: nil,
|
||||
health: nil,
|
||||
error: error.localizedDescription)
|
||||
printConnectOutput(output, json: opts.json)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
|
||||
if json {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(output),
|
||||
let text = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(text)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Clawdbot macOS Gateway Connect")
|
||||
print("Status: \(output.status)")
|
||||
print("URL: \(output.url)")
|
||||
print("Mode: \(output.mode)")
|
||||
print("Client: \(output.clientId) (\(output.clientMode))")
|
||||
print("Role: \(output.role)")
|
||||
print("Scopes: \(output.scopes.joined(separator: ", "))")
|
||||
if let snapshot = output.snapshot {
|
||||
print("Protocol: \(snapshot._protocol)")
|
||||
if let version = snapshot.server["version"]?.value as? String {
|
||||
print("Server: \(version)")
|
||||
}
|
||||
}
|
||||
if let health = output.health,
|
||||
let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool
|
||||
{
|
||||
print("Health: \(ok ? "ok" : "error")")
|
||||
} else if output.health != nil {
|
||||
print("Health: received")
|
||||
}
|
||||
if let error = output.error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else {
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
if resolvedMode == "remote" {
|
||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||
}
|
||||
guard let url = URL(string: raw) else {
|
||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
let host = resolveLocalHost(bind: config.bind)
|
||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
|
||||
password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
|
||||
mode: resolvedMode)
|
||||
}
|
||||
|
||||
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||
try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||
}
|
||||
|
||||
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||
if let token = opts.token, !token.isEmpty { return token }
|
||||
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
if mode == "remote" {
|
||||
return config.remoteToken
|
||||
}
|
||||
return config.token
|
||||
}
|
||||
|
||||
private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||
if let password = opts.password, !password.isEmpty { return password }
|
||||
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
|
||||
return password
|
||||
}
|
||||
if mode == "remote" {
|
||||
return config.remotePassword
|
||||
}
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func resolveLocalHost(bind: String?) -> String {
|
||||
let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let tailnetIP = detectTailnetIPv4()
|
||||
switch normalized {
|
||||
case "tailnet":
|
||||
return tailnetIP ?? "127.0.0.1"
|
||||
default:
|
||||
return "127.0.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
private func detectTailnetIPv4() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if isTailnetIPv4(ip) { return ip }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
149
apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift
Normal file
149
apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
func runDiscover(_ args: [String]) async {
|
||||
let opts = DiscoveryOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac discover
|
||||
|
||||
Usage:
|
||||
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = await MainActor.run {
|
||||
GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
56
apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift
Normal file
56
apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
private struct RootCommand {
|
||||
var name: String
|
||||
var args: [String]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotMacCLI {
|
||||
static func main() async {
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
let command = parseRootCommand(args)
|
||||
switch command?.name {
|
||||
case nil:
|
||||
printUsage()
|
||||
case "-h", "--help", "help":
|
||||
printUsage()
|
||||
case "connect":
|
||||
await runConnect(command?.args ?? [])
|
||||
case "discover":
|
||||
await runDiscover(command?.args ?? [])
|
||||
case "wizard":
|
||||
await runWizardCommand(command?.args ?? [])
|
||||
default:
|
||||
fputs("clawdbot-mac: unknown command\n", stderr)
|
||||
printUsage()
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseRootCommand(_ args: [String]) -> RootCommand? {
|
||||
guard let first = args.first else { return nil }
|
||||
return RootCommand(name: first, args: Array(args.dropFirst()))
|
||||
}
|
||||
|
||||
private func printUsage() {
|
||||
print("""
|
||||
clawdbot-mac
|
||||
|
||||
Usage:
|
||||
clawdbot-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--timeout <ms>] [--probe] [--json]
|
||||
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
|
||||
[--role <role>] [--scopes <a,b,c>]
|
||||
clawdbot-mac discover [--timeout <ms>] [--json] [--include-local]
|
||||
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Examples:
|
||||
clawdbot-mac connect
|
||||
clawdbot-mac connect --url ws://127.0.0.1:18789 --json
|
||||
clawdbot-mac discover --timeout 3000 --json
|
||||
clawdbot-mac wizard --mode local
|
||||
""")
|
||||
}
|
||||
60
apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift
Normal file
60
apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewayConfig {
|
||||
var mode: String?
|
||||
var bind: String?
|
||||
var port: Int?
|
||||
var remoteUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var remoteToken: String?
|
||||
var remotePassword: String?
|
||||
}
|
||||
|
||||
struct GatewayEndpoint {
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
let mode: String
|
||||
}
|
||||
|
||||
func loadGatewayConfig() -> GatewayConfig {
|
||||
let url = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return GatewayConfig()
|
||||
}
|
||||
|
||||
var cfg = GatewayConfig()
|
||||
if let gateway = json["gateway"] as? [String: Any] {
|
||||
cfg.mode = gateway["mode"] as? String
|
||||
cfg.bind = gateway["bind"] as? String
|
||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||
|
||||
if let auth = gateway["auth"] as? [String: Any] {
|
||||
cfg.token = auth["token"] as? String
|
||||
cfg.password = auth["password"] as? String
|
||||
}
|
||||
if let remote = gateway["remote"] as? [String: Any] {
|
||||
cfg.remoteUrl = remote["url"] as? String
|
||||
cfg.remoteToken = remote["token"] as? String
|
||||
cfg.remotePassword = remote["password"] as? String
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseInt(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as Double:
|
||||
Int(number)
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
5
apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift
Normal file
5
apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift
Normal file
@@ -0,0 +1,5 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
typealias KitAnyCodable = ClawdbotKit.AnyCodable
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Darwin
|
||||
import Foundation
|
||||
@@ -48,17 +49,6 @@ struct WizardCliOptions {
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayConfig {
|
||||
var mode: String?
|
||||
var bind: String?
|
||||
var port: Int?
|
||||
var remoteUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var remoteToken: String?
|
||||
var remotePassword: String?
|
||||
}
|
||||
|
||||
enum WizardCliError: Error, CustomStringConvertible {
|
||||
case invalidUrl(String)
|
||||
case missingRemoteUrl
|
||||
@@ -77,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotWizardCLI {
|
||||
static func main() async {
|
||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
func runWizardCommand(_ args: [String]) async {
|
||||
let opts = WizardCliOptions.parse(args)
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac wizard
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
}
|
||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||
let client = GatewayWizardClient(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
json: opts.json)
|
||||
try await client.connect()
|
||||
defer { Task { await client.close() } }
|
||||
try await runWizard(client: client, opts: opts)
|
||||
} catch {
|
||||
fputs("wizard: \(error)\n", stderr)
|
||||
exit(1)
|
||||
Usage:
|
||||
clawdbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Wizard mode (local|remote). Default: local
|
||||
--workspace <path> Wizard workspace override
|
||||
--json Print raw wizard responses
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
}
|
||||
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
|
||||
let client = GatewayWizardClient(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
json: opts.json)
|
||||
try await client.connect()
|
||||
defer { Task { await client.close() } }
|
||||
try await runWizard(client: client, opts: opts)
|
||||
} catch {
|
||||
fputs("wizard: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayEndpoint {
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private func printUsage() {
|
||||
print("""
|
||||
clawdbot-mac-wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Wizard mode (local|remote). Default: local
|
||||
--workspace <path> Wizard workspace override
|
||||
--json Print raw wizard responses
|
||||
-h, --help Show help
|
||||
""")
|
||||
}
|
||||
|
||||
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: (config.mode ?? "local").lowercased())
|
||||
}
|
||||
|
||||
let mode = (config.mode ?? "local").lowercased()
|
||||
@@ -150,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: mode)
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
@@ -161,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
password: resolvedPassword(opts: opts, config: config),
|
||||
mode: mode)
|
||||
}
|
||||
|
||||
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||
@@ -186,48 +166,11 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func loadGatewayConfig() -> GatewayConfig {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return GatewayConfig()
|
||||
}
|
||||
|
||||
var cfg = GatewayConfig()
|
||||
if let gateway = json["gateway"] as? [String: Any] {
|
||||
cfg.mode = gateway["mode"] as? String
|
||||
cfg.bind = gateway["bind"] as? String
|
||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||
|
||||
if let auth = gateway["auth"] as? [String: Any] {
|
||||
cfg.token = auth["token"] as? String
|
||||
cfg.password = auth["password"] as? String
|
||||
}
|
||||
if let remote = gateway["remote"] as? [String: Any] {
|
||||
cfg.remoteUrl = remote["url"] as? String
|
||||
cfg.remoteToken = remote["token"] as? String
|
||||
cfg.remotePassword = remote["password"] as? String
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
private func parseInt(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as Double:
|
||||
Int(number)
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
actor GatewayWizardClient {
|
||||
private enum ConnectChallengeError: Error {
|
||||
case timeout
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let token: String?
|
||||
private let password: String?
|
||||
@@ -235,6 +178,7 @@ actor GatewayWizardClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let session = URLSession(configuration: .default)
|
||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
init(url: URL, token: String?, password: String?, json: Bool) {
|
||||
@@ -257,7 +201,7 @@ actor GatewayWizardClient {
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
|
||||
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
|
||||
guard let task = self.task else {
|
||||
throw WizardCliError.gatewayError("gateway not connected")
|
||||
}
|
||||
@@ -266,7 +210,7 @@ actor GatewayWizardClient {
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: params.map { AnyCodable($0) })
|
||||
params: params.map { ProtoAnyCodable($0) })
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
@@ -309,28 +253,66 @@ actor GatewayWizardClient {
|
||||
}
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let client: [String: AnyCodable] = [
|
||||
"id": AnyCodable("clawdbot-macos"),
|
||||
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||
"version": AnyCodable("dev"),
|
||||
"platform": AnyCodable(platform),
|
||||
"deviceFamily": AnyCodable("Mac"),
|
||||
"mode": AnyCodable("ui"),
|
||||
"instanceId": AnyCodable(UUID().uuidString),
|
||||
let clientId = "clawdbot-macos"
|
||||
let clientMode = "ui"
|
||||
let role = "operator"
|
||||
let scopes: [String] = []
|
||||
let client: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||
"version": ProtoAnyCodable("dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"deviceFamily": ProtoAnyCodable("Mac"),
|
||||
"mode": ProtoAnyCodable(clientMode),
|
||||
"instanceId": ProtoAnyCodable(UUID().uuidString),
|
||||
]
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": AnyCodable(client),
|
||||
"caps": AnyCodable([String]()),
|
||||
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
var params: [String: ProtoAnyCodable] = [
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable([String]()),
|
||||
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable(role),
|
||||
"scopes": ProtoAnyCodable(scopes),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = AnyCodable(["token": AnyCodable(token)])
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = AnyCodable(["password": AnyCodable(password)])
|
||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||
}
|
||||
let connectNonce = try await self.waitForConnectChallenge()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopesValue,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
]
|
||||
if let connectNonce {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
|
||||
{
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
@@ -338,31 +320,58 @@ actor GatewayWizardClient {
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: AnyCodable(params))
|
||||
params: ProtoAnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
let message = try await task.receive()
|
||||
let frameResponse = try decodeFrame(message)
|
||||
guard case let .res(res) = frameResponse, res.id == reqId else {
|
||||
throw WizardCliError.gatewayError("connect failed (unexpected response)")
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frameResponse = try decodeFrame(message)
|
||||
if case let .res(res) = frameResponse, res.id == reqId {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
return
|
||||
}
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
|
||||
private func waitForConnectChallenge() async throws -> String? {
|
||||
guard let task = self.task else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectChallengeTimeoutSeconds,
|
||||
onTimeout: { ConnectChallengeError.timeout },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try await self.decodeFrame(message)
|
||||
if case let .event(evt) = frame, evt.event == "connect.challenge" {
|
||||
if let payload = evt.payload?.value as? [String: ProtoAnyCodable],
|
||||
let nonce = payload["nonce"]?.value as? String
|
||||
{
|
||||
return nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
throw error
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
}
|
||||
}
|
||||
|
||||
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
|
||||
var params: [String: AnyCodable] = [:]
|
||||
var params: [String: ProtoAnyCodable] = [:]
|
||||
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if mode == "local" || mode == "remote" {
|
||||
params["mode"] = AnyCodable(mode)
|
||||
params["mode"] = ProtoAnyCodable(mode)
|
||||
}
|
||||
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
||||
params["workspace"] = AnyCodable(workspace)
|
||||
params["workspace"] = ProtoAnyCodable(workspace)
|
||||
}
|
||||
|
||||
let startResponse = try await client.request(method: "wizard.start", params: params)
|
||||
@@ -395,17 +404,17 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
|
||||
if let step = decodeWizardStep(nextResult.step) {
|
||||
let answer = try promptAnswer(for: step)
|
||||
var answerPayload: [String: AnyCodable] = [
|
||||
"stepId": AnyCodable(step.id),
|
||||
var answerPayload: [String: ProtoAnyCodable] = [
|
||||
"stepId": ProtoAnyCodable(step.id),
|
||||
]
|
||||
if !(answer is NSNull) {
|
||||
answerPayload["value"] = AnyCodable(answer)
|
||||
answerPayload["value"] = ProtoAnyCodable(answer)
|
||||
}
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"answer": AnyCodable(answerPayload),
|
||||
"sessionId": ProtoAnyCodable(sessionId),
|
||||
"answer": ProtoAnyCodable(answerPayload),
|
||||
])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
@@ -414,7 +423,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
} else {
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
dumpResult(response)
|
||||
@@ -424,7 +433,7 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn
|
||||
} catch WizardCliError.cancelled {
|
||||
_ = try? await client.request(
|
||||
method: "wizard.cancel",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
params: ["sessionId": ProtoAnyCodable(sessionId)])
|
||||
throw WizardCliError.cancelled
|
||||
}
|
||||
}
|
||||
2422
apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
Normal file
2422
apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import Testing
|
||||
struct AgentWorkspaceTests {
|
||||
@Test
|
||||
func displayPathUsesTildeForHome() {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
||||
|
||||
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
||||
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
|
||||
|
||||
@Test
|
||||
func bootstrapCreatesAgentsFileWhenMissing() throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
|
||||
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
#expect(FileManager.default.fileExists(atPath: agentsURL.path))
|
||||
#expect(FileManager().fileExists(atPath: agentsURL.path))
|
||||
|
||||
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
|
||||
#expect(contents.contains("# AGENTS.md"))
|
||||
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
|
||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||
#expect(FileManager.default.fileExists(atPath: identityURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: userURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
|
||||
#expect(FileManager().fileExists(atPath: identityURL.path))
|
||||
#expect(FileManager().fileExists(atPath: userURL.path))
|
||||
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
|
||||
|
||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
#expect(second == agentsURL)
|
||||
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
|
||||
|
||||
@Test
|
||||
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let marker = tmp.appendingPathComponent("notes.txt")
|
||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||
|
||||
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
|
||||
|
||||
@Test
|
||||
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
||||
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
||||
|
||||
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
|
||||
|
||||
@Test
|
||||
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let marker = tmp.appendingPathComponent("notes.txt")
|
||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||
|
||||
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||
|
||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
|
||||
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
|
||||
}
|
||||
|
||||
@Test
|
||||
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
||||
let tmp = FileManager.default.temporaryDirectory
|
||||
let tmp = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
defer { try? FileManager().removeItem(at: tmp) }
|
||||
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||
try """
|
||||
# IDENTITY.md - Agent Identity
|
||||
|
||||
@@ -6,9 +6,9 @@ import Testing
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
let dir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||
let payload = [
|
||||
"anthropic": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user