Compare commits
1114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5672a25097 | ||
|
|
2d2f840464 | ||
|
|
bbbcecf62f | ||
|
|
1506d493ea | ||
|
|
0c855bd36a | ||
|
|
b861a0bd73 | ||
|
|
e43f4c0628 | ||
|
|
a8ad242f88 | ||
|
|
343882d45c | ||
|
|
5c35b62a5c | ||
|
|
af9606de36 | ||
|
|
5aa02cf3f7 | ||
|
|
91d5ea6e33 | ||
|
|
82746973d4 | ||
|
|
cd7be58b8e | ||
|
|
107f07ad69 | ||
|
|
99ce47e86a | ||
|
|
2a709385f8 | ||
|
|
0aa48a26d1 | ||
|
|
6cbdd767af | ||
|
|
2807f5afbc | ||
|
|
b3a60af71c | ||
|
|
fe1f2d971a | ||
|
|
3888f1edc6 | ||
|
|
0f8f0fb9d7 | ||
|
|
9c0c5866db | ||
|
|
147842fadc | ||
|
|
420e5299d2 | ||
|
|
6d26971051 | ||
|
|
820ab8765a | ||
|
|
20f6a5546f | ||
|
|
fb14146033 | ||
|
|
d34ae86114 | ||
|
|
fbc5ac1fde | ||
|
|
ff382f6b68 | ||
|
|
bc8c31eeed | ||
|
|
bdea265704 | ||
|
|
ec75e0b3dc | ||
|
|
9e6b45faab | ||
|
|
8e051a418f | ||
|
|
a5b99349c9 | ||
|
|
1371e95e57 | ||
|
|
320b45c051 | ||
|
|
97248a2885 | ||
|
|
f5c90f0e5c | ||
|
|
c5ffc11df5 | ||
|
|
1a947a21d6 | ||
|
|
2cbc991bfe | ||
|
|
10d5ea5de6 | ||
|
|
34b3494246 | ||
|
|
fba7afaa12 | ||
|
|
000d5508aa | ||
|
|
aa2a1a17e3 | ||
|
|
526303d9a2 | ||
|
|
39d219da59 | ||
|
|
f625303d13 | ||
|
|
3314b3996e | ||
|
|
ab73aceb27 | ||
|
|
42d039998d | ||
|
|
3497be2963 | ||
|
|
d62b7c0d1e | ||
|
|
2ad3508a33 | ||
|
|
b9098f3401 | ||
|
|
e6bdffe568 | ||
|
|
a486940781 | ||
|
|
2a4ccb624a | ||
|
|
e0b8661eee | ||
|
|
bfe9bb8a23 | ||
|
|
287ab84060 | ||
|
|
b06fc50e25 | ||
|
|
94ead83ba4 | ||
|
|
db2395744b | ||
|
|
b8e6f0b135 | ||
|
|
0e3340d1fc | ||
|
|
ce60c6db1b | ||
|
|
c01cc61f9a | ||
|
|
5b6a211583 | ||
|
|
b9643ad60e | ||
|
|
07e34e3423 | ||
|
|
3e07bd8b48 | ||
|
|
97200984f8 | ||
|
|
b623557a2e | ||
|
|
8b68cdd9bc | ||
|
|
403c397ff5 | ||
|
|
ded366d9ab | ||
|
|
300cda5d7d | ||
|
|
961b4adc1c | ||
|
|
f3e3c4573b | ||
|
|
e162676e51 | ||
|
|
6918fbc0bd | ||
|
|
c08572cd18 | ||
|
|
d37df28319 | ||
|
|
4e9756a3e1 | ||
|
|
a187cd47f7 | ||
|
|
592930f10f | ||
|
|
112f4e3d01 | ||
|
|
a1f9825d63 | ||
|
|
1da6c05e62 | ||
|
|
58949a1f95 | ||
|
|
c4a80f4edb | ||
|
|
fd9be79be1 | ||
|
|
6859e1e6a6 | ||
|
|
9ba4b1e32b | ||
|
|
fabdf2f6f7 | ||
|
|
08183fe009 | ||
|
|
34ce004151 | ||
|
|
49ef62255e | ||
|
|
e040f6338a | ||
|
|
9ba142e8a5 | ||
|
|
a2d9127ff6 | ||
|
|
10914d6249 | ||
|
|
d696ee3dfd | ||
|
|
5172098073 | ||
|
|
5d6a9da370 | ||
|
|
0648d660a8 | ||
|
|
15f7648e1e | ||
|
|
8b91ceb7c9 | ||
|
|
7e4e24445e | ||
|
|
678ad9e3ae | ||
|
|
1b598ad709 | ||
|
|
7f6422c897 | ||
|
|
7187c3d067 | ||
|
|
1f06f8031e | ||
|
|
73507e8654 | ||
|
|
9ecbb0ae81 | ||
|
|
84f8f8b10e | ||
|
|
47101da464 | ||
|
|
a989fe8af9 | ||
|
|
6d60c32570 | ||
|
|
5d2ef89e03 | ||
|
|
159f6bfddd | ||
|
|
9c8e8c5c2d | ||
|
|
28fe95ac5e | ||
|
|
b25fcaef0f | ||
|
|
6b6284c69c | ||
|
|
136f0d4d1d | ||
|
|
a21671ed5b | ||
|
|
c7fabb43f9 | ||
|
|
9c26cded75 | ||
|
|
138916a0d1 | ||
|
|
7ea4b06a04 | ||
|
|
44bf454508 | ||
|
|
5c231fc21f | ||
|
|
8f6542409a | ||
|
|
50b4126c79 | ||
|
|
e0adf65dac | ||
|
|
6ffc5d93e4 | ||
|
|
ae030c32da | ||
|
|
ffaeee4c39 | ||
|
|
68824c8903 | ||
|
|
e40257af33 | ||
|
|
c8063bdcd8 | ||
|
|
4f82de3dcc | ||
|
|
885167dd58 | ||
|
|
6aec34bc60 | ||
|
|
1c606fdb57 | ||
|
|
d1dd8a1d69 | ||
|
|
a22ac64c47 | ||
|
|
71eb6d5dd0 | ||
|
|
a14ca1a337 | ||
|
|
4c11fc0c09 | ||
|
|
0130ecd800 | ||
|
|
c3f5b4c416 | ||
|
|
0975aa4a7c | ||
|
|
46fa1c1301 | ||
|
|
03e9a076b8 | ||
|
|
22cf2b6766 | ||
|
|
97487a51a0 | ||
|
|
9bd5def32c | ||
|
|
8257ec6a1f | ||
|
|
abedc8bf7f | ||
|
|
f618859761 | ||
|
|
015c256984 | ||
|
|
5a21722f32 | ||
|
|
6110514606 | ||
|
|
7a5e103a6a | ||
|
|
4e23b7f654 | ||
|
|
7253bf398d | ||
|
|
026def686e | ||
|
|
003fff067a | ||
|
|
8f3da653b0 | ||
|
|
0f5f7ec22a | ||
|
|
2fcbed2111 | ||
|
|
c96ffa7186 | ||
|
|
101d0f451f | ||
|
|
875b018ea1 | ||
|
|
b6581e77f6 | ||
|
|
81e915110e | ||
|
|
7e9aa3c275 | ||
|
|
65e2d939e1 | ||
|
|
67db63ba05 | ||
|
|
bbefb2e5a5 | ||
|
|
50f233d16d | ||
|
|
48aea87028 | ||
|
|
612a27f3dd | ||
|
|
737037129e | ||
|
|
f29f51569a | ||
|
|
bfa57aae44 | ||
|
|
98cecc9c56 | ||
|
|
6cc1f5abb8 | ||
|
|
9fbee08590 | ||
|
|
0f662c2935 | ||
|
|
32bcd291d5 | ||
|
|
5f9863098b | ||
|
|
fdecf5c59a | ||
|
|
83f92e34af | ||
|
|
9366cbc7db | ||
|
|
d4f895d8f2 | ||
|
|
f08c34a73f | ||
|
|
6a9301c27d | ||
|
|
653401774d | ||
|
|
8b4696c087 | ||
|
|
c6cdbb630c | ||
|
|
da2439f2cc | ||
|
|
92ab3f22dc | ||
|
|
43a6c5b77f | ||
|
|
495616d13e | ||
|
|
bac80f0886 | ||
|
|
ef078fec70 | ||
|
|
8e3ac01db6 | ||
|
|
c3f90dd4e2 | ||
|
|
00c4556d7b | ||
|
|
a4bc69dbec | ||
|
|
3f1457de2a | ||
|
|
8507ea08bd | ||
|
|
7ae2548fc6 | ||
|
|
f06f83ddd0 | ||
|
|
c78297d80f | ||
|
|
69f6e1a20b | ||
|
|
5f6409a73d | ||
|
|
06a7e1e8ce | ||
|
|
9eaaadf8ee | ||
|
|
d4f60bf16a | ||
|
|
ede5145191 | ||
|
|
26d3fbb09f | ||
|
|
f7c89ba796 | ||
|
|
9afde64e26 | ||
|
|
8682524da3 | ||
|
|
5956dde459 | ||
|
|
50bb418fe7 | ||
|
|
458e731f8b | ||
|
|
ca78ccf74c | ||
|
|
580fd7abbd | ||
|
|
4a82c258c7 | ||
|
|
58c7c61e62 | ||
|
|
629ce4454d | ||
|
|
617d8a12d7 | ||
|
|
cdceff2284 | ||
|
|
cb52ffb842 | ||
|
|
3a35d313d9 | ||
|
|
116fbb747f | ||
|
|
b1a555da13 | ||
|
|
c92aaca8b0 | ||
|
|
c3e777e3e1 | ||
|
|
2e3b14187b | ||
|
|
f8a22521bd | ||
|
|
8477394414 | ||
|
|
e6e71457e0 | ||
|
|
2684a364c6 | ||
|
|
b9dc117309 | ||
|
|
9205ee55de | ||
|
|
6e23e81678 | ||
|
|
0163f53f5d | ||
|
|
25f2d2adb3 | ||
|
|
7540d1e8c1 | ||
|
|
fc0e303e05 | ||
|
|
6a7a1d7085 | ||
|
|
92e794dc18 | ||
|
|
6375ee836f | ||
|
|
a6c97b5a48 | ||
|
|
5ea15ff7fe | ||
|
|
dd57483e5e | ||
|
|
3696aade09 | ||
|
|
426168a338 | ||
|
|
2f58d59f22 | ||
|
|
cbe19ad2f2 | ||
|
|
d57b88c7af | ||
|
|
ce89bc2b40 | ||
|
|
85b27fe5fe | ||
|
|
c147962434 | ||
|
|
72858a5311 | ||
|
|
21445cfc0a | ||
|
|
5ad203e47b | ||
|
|
3b53213b41 | ||
|
|
81c6ab0ec0 | ||
|
|
3ea887be5a | ||
|
|
c565de0f71 | ||
|
|
913d2f4b3e | ||
|
|
8e159ab0b7 | ||
|
|
5570e1a946 | ||
|
|
99dae0302b | ||
|
|
c64184fcfa | ||
|
|
70e7034a1c | ||
|
|
5991bed32e | ||
|
|
0f6e39b9e8 | ||
|
|
b76cd6695d | ||
|
|
60661441b1 | ||
|
|
0752ae6d6d | ||
|
|
1b17453942 | ||
|
|
ee2918c3b1 | ||
|
|
e5aa84ee48 | ||
|
|
445b58550c | ||
|
|
c2d68a87f7 | ||
|
|
51e3d16be9 | ||
|
|
c00cbd080d | ||
|
|
dd150d69c6 | ||
|
|
9ceac415c5 | ||
|
|
ac00065727 | ||
|
|
30534c5c33 | ||
|
|
97755683c7 | ||
|
|
a4f6b3528a | ||
|
|
9f8e66359e | ||
|
|
8a2720db4c | ||
|
|
5330595a5a | ||
|
|
2c5141d7df | ||
|
|
483fba41b9 | ||
|
|
fe7436a1f6 | ||
|
|
a1ed671636 | ||
|
|
8c47d226ad | ||
|
|
e1942603e9 | ||
|
|
926c2647b8 | ||
|
|
c427f4a2fc | ||
|
|
f99f9a6b64 | ||
|
|
39d8c441eb | ||
|
|
40ef3b5d30 | ||
|
|
390b730b37 | ||
|
|
71457fa100 | ||
|
|
da7a45b3a5 | ||
|
|
15a9c21203 | ||
|
|
6d79c6cd26 | ||
|
|
bcedeb4e1f | ||
|
|
f076eba98a | ||
|
|
f3bd6bf342 | ||
|
|
c29c9a1e3e | ||
|
|
7a384ea07c | ||
|
|
5fc866e8fe | ||
|
|
437535ee94 | ||
|
|
3b929ff843 | ||
|
|
93737ee152 | ||
|
|
765626b492 | ||
|
|
42b8fce4e5 | ||
|
|
c27294133e | ||
|
|
94095386b3 | ||
|
|
7a524e8667 | ||
|
|
876bbb742a | ||
|
|
ef7971e3a4 | ||
|
|
9d742ba51f | ||
|
|
386d21b6d1 | ||
|
|
c8afa8207c | ||
|
|
d0e21f05a6 | ||
|
|
11f039ef85 | ||
|
|
e90e3ba954 | ||
|
|
0de7852d46 | ||
|
|
834663dfef | ||
|
|
a72d7a9f36 | ||
|
|
67e57e7c99 | ||
|
|
4c98d6c121 | ||
|
|
174a1cb68a | ||
|
|
a4d56bd06e | ||
|
|
c9e98376b3 | ||
|
|
62c9255b6a | ||
|
|
8b4e40c602 | ||
|
|
6a9d7f7a01 | ||
|
|
39d8e9be0f | ||
|
|
ac45c8b404 | ||
|
|
fa746b05de | ||
|
|
49c518951c | ||
|
|
0dca8acbe2 | ||
|
|
c42e9b1d19 | ||
|
|
298901208d | ||
|
|
ef9ba66798 | ||
|
|
4b6cdd1d3c | ||
|
|
eaeb52f70a | ||
|
|
be1cdc9370 | ||
|
|
8002143d92 | ||
|
|
4a9123d415 | ||
|
|
dbf139d14e | ||
|
|
d905ca0e02 | ||
|
|
ab000398be | ||
|
|
1bbbb10abf | ||
|
|
c02204fd1e | ||
|
|
5482803547 | ||
|
|
3dcaa70531 | ||
|
|
a6ddd82a14 | ||
|
|
585e20b72e | ||
|
|
d8a6317dfc | ||
|
|
c8c58c0537 | ||
|
|
cfdd5a8c2e | ||
|
|
6765fd15eb | ||
|
|
4074fa0471 | ||
|
|
ea2ccd8ae6 | ||
|
|
b1ac7e0501 | ||
|
|
b4a2dc81a2 | ||
|
|
d73e8ecca3 | ||
|
|
faa90fc206 | ||
|
|
f1083cd52c | ||
|
|
7f7550e53c | ||
|
|
d4d17025cf | ||
|
|
7b76db2841 | ||
|
|
f9cf508cff | ||
|
|
9b12275fe1 | ||
|
|
f70ac0c7c2 | ||
|
|
09a72f1ede | ||
|
|
2b8b3c4b10 | ||
|
|
8ea8801d06 | ||
|
|
c97bf23a4a | ||
|
|
3fff943ba1 | ||
|
|
90685ef814 | ||
|
|
a8f2ac5411 | ||
|
|
dea96a2c3d | ||
|
|
90ae2f541c | ||
|
|
d9a467fe3b | ||
|
|
aef88cd9f1 | ||
|
|
104d977d12 | ||
|
|
4b24753be7 | ||
|
|
df09e583aa | ||
|
|
46e6546bb9 | ||
|
|
5428c97685 | ||
|
|
202d7af855 | ||
|
|
72020b37c3 | ||
|
|
b051621bd4 | ||
|
|
ff52aec38e | ||
|
|
15620b1092 | ||
|
|
ad7fc4964a | ||
|
|
8f4426052c | ||
|
|
6a60d47c53 | ||
|
|
b1482957f5 | ||
|
|
4d2e9e8113 | ||
|
|
72d62a54c6 | ||
|
|
ae48066d28 | ||
|
|
f56f799990 | ||
|
|
7e498ab94a | ||
|
|
6bd6ae41b1 | ||
|
|
f648aae440 | ||
|
|
b56587f26e | ||
|
|
4ee808dbcb | ||
|
|
66eec295b8 | ||
|
|
675019cb6f | ||
|
|
795b592286 | ||
|
|
9d98e55ed5 | ||
|
|
c07949a99c | ||
|
|
e51bf46abe | ||
|
|
eba0625a70 | ||
|
|
886752217d | ||
|
|
5662a9cdfc | ||
|
|
fd23b9b209 | ||
|
|
975f5a5284 | ||
|
|
63176ccb8a | ||
|
|
6c3a9fc092 | ||
|
|
d9f173a03d | ||
|
|
c3cb26f7ca | ||
|
|
dd06028827 | ||
|
|
71203829d8 | ||
|
|
dfa80e1e5d | ||
|
|
951a4ea065 | ||
|
|
4fa1517e6d | ||
|
|
de2d986008 | ||
|
|
d57cb2e1a8 | ||
|
|
b697374ce5 | ||
|
|
b9106ba5f9 | ||
|
|
3ba9821254 | ||
|
|
17f2a990a8 | ||
|
|
71f7bd1cfd | ||
|
|
c4c01089ab | ||
|
|
b6591c3f69 | ||
|
|
e6fdbae79b | ||
|
|
a4e57d3ac4 | ||
|
|
1d862cf5c2 | ||
|
|
0840029982 | ||
|
|
309fcc5321 | ||
|
|
00ae21bed2 | ||
|
|
00fd57b8f5 | ||
|
|
aabe0bed30 | ||
|
|
350131b4d7 | ||
|
|
95d45c0aa7 | ||
|
|
cb06e133ca | ||
|
|
4e77483051 | ||
|
|
81535d512a | ||
|
|
8effb557d5 | ||
|
|
c66b1fd18b | ||
|
|
c04f8ba1ea | ||
|
|
c1b7f6b6ba | ||
|
|
e4708b3b99 | ||
|
|
f938f6617b | ||
|
|
e882f7d207 | ||
|
|
e38fd8603f | ||
|
|
89283aa788 | ||
|
|
f7dc27f2d0 | ||
|
|
ed560e466f | ||
|
|
b5f1dc9d95 | ||
|
|
f58ad7625f | ||
|
|
49c6d8019f | ||
|
|
86db180a17 | ||
|
|
c69111a4e6 | ||
|
|
31e59cd583 | ||
|
|
d2bfcd70e7 | ||
|
|
12d22e1c89 | ||
|
|
75cb78a5b1 | ||
|
|
791b568f78 | ||
|
|
d46642319b | ||
|
|
a96d37ca69 | ||
|
|
f8046268bc | ||
|
|
9cdd0c28be | ||
|
|
05b0b82937 | ||
|
|
908d9331af | ||
|
|
29f0463f65 | ||
|
|
66f353fe7a | ||
|
|
511a0c22b7 | ||
|
|
da3f2b4898 | ||
|
|
438e782f81 | ||
|
|
d354030974 | ||
|
|
ef777d6bb6 | ||
|
|
b9c35d9fdc | ||
|
|
69f645c662 | ||
|
|
efec5fc751 | ||
|
|
bf4544784a | ||
|
|
c9a7c77b24 | ||
|
|
aeb6b2ffad | ||
|
|
07ce1d73ff | ||
|
|
1113f17d4c | ||
|
|
8252ae2da1 | ||
|
|
d82ecaf9dc | ||
|
|
521ea4ae5b | ||
|
|
05e7e06146 | ||
|
|
cb8c8fee9a | ||
|
|
ed05152cb1 | ||
|
|
a8054d1e83 | ||
|
|
2e0a835e07 | ||
|
|
da26954dd0 | ||
|
|
892197c43e | ||
|
|
02bd6e4a24 | ||
|
|
99d4820b39 | ||
|
|
022aa10063 | ||
|
|
ae0741a346 | ||
|
|
4ee70be690 | ||
|
|
24de8cecf6 | ||
|
|
fdbaae6a33 | ||
|
|
7d0a0ae3ba | ||
|
|
242add587f | ||
|
|
6fba598eaf | ||
|
|
75a54f0259 | ||
|
|
c63144ab14 | ||
|
|
f07c39b265 | ||
|
|
40181afded | ||
|
|
2f1b9efe9a | ||
|
|
ff30cef8a4 | ||
|
|
3d958d5466 | ||
|
|
cad7ed1cb8 | ||
|
|
8195497cec | ||
|
|
1af227b619 | ||
|
|
b77e730657 | ||
|
|
37e5f077b8 | ||
|
|
0eb7e1864c | ||
|
|
0d336272f9 | ||
|
|
ace6a42ea6 | ||
|
|
6d2a1ce217 | ||
|
|
c9d73469c3 | ||
|
|
29353e2e81 | ||
|
|
fdc50a0feb | ||
|
|
a1413a011e | ||
|
|
bfbeea0f20 | ||
|
|
2c85b1b409 | ||
|
|
8b7b7e154f | ||
|
|
bb9bddebb4 | ||
|
|
6e570561b6 | ||
|
|
fb6363ae58 | ||
|
|
1b77e086d4 | ||
|
|
d371a4c8c3 | ||
|
|
03e8b7c4ba | ||
|
|
8aadcaa1bd | ||
|
|
96800c27ec | ||
|
|
13d1712850 | ||
|
|
c5546f0d5b | ||
|
|
3de5ea818d | ||
|
|
dc07f1e021 | ||
|
|
310a248a44 | ||
|
|
88e7684258 | ||
|
|
716f901504 | ||
|
|
e817c0cee5 | ||
|
|
e634791585 | ||
|
|
78071f8ec4 | ||
|
|
c48751a99c | ||
|
|
86e0916fa3 | ||
|
|
dc89bc4004 | ||
|
|
0c7e649676 | ||
|
|
45ce07a098 | ||
|
|
aed8dc1ade | ||
|
|
86a341be62 | ||
|
|
ff78e9a564 | ||
|
|
60a60779d7 | ||
|
|
32da00cb2f | ||
|
|
0420f2804c | ||
|
|
4de660bec6 | ||
|
|
58f638463f | ||
|
|
f1afc722da | ||
|
|
bc75d58e9e | ||
|
|
2efd265697 | ||
|
|
9c1f1476bc | ||
|
|
e8352c8d21 | ||
|
|
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 | ||
|
|
4ad9622efb | ||
|
|
2f0dd9c4ee | ||
|
|
2af497495f | ||
|
|
056b3e40d6 | ||
|
|
6402a48482 | ||
|
|
7e59c15496 | ||
|
|
079af0d0b0 | ||
|
|
ed909d6013 | ||
|
|
9497ffcc50 | ||
|
|
032c780a79 | ||
|
|
e011c764a7 | ||
|
|
b2650ba672 | ||
|
|
147fccd967 |
No files matched your search
Vendored
BIN
Binary file not shown.
@@ -7,6 +7,10 @@
|
|||||||
[exclude-files]
|
[exclude-files]
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
pattern = (^|/)pnpm-lock\.yaml$
|
||||||
|
# Generated output and vendored assets.
|
||||||
|
pattern = (^|/)(dist|vendor)/
|
||||||
|
# Local config file with allowlist patterns.
|
||||||
|
pattern = (^|/)\.detect-secrets\.cfg$
|
||||||
|
|
||||||
[exclude-lines]
|
[exclude-lines]
|
||||||
# Fastlane checks for private key marker; not a real key.
|
# Fastlane checks for private key marker; not a real key.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
custom: ['https://github.com/sponsors/steipete']
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# actionlint configuration
|
||||||
|
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||||
|
|
||||||
|
self-hosted-runner:
|
||||||
|
labels:
|
||||||
|
# Blacksmith CI runners
|
||||||
|
- blacksmith-4vcpu-ubuntu-2404
|
||||||
|
- blacksmith-4vcpu-windows-2025
|
||||||
|
|
||||||
|
# Ignore patterns for known issues
|
||||||
|
paths:
|
||||||
|
.github/workflows/**/*.yml:
|
||||||
|
ignore:
|
||||||
|
# Ignore shellcheck warnings (we run shellcheck separately)
|
||||||
|
- 'shellcheck reported issue.+'
|
||||||
|
# Ignore intentional if: false for disabled jobs
|
||||||
|
- 'constant expression "false" in condition'
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Dependabot configuration
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
registries:
|
||||||
|
npm-npmjs:
|
||||||
|
type: npm-registry
|
||||||
|
url: https://registry.npmjs.org
|
||||||
|
replaces-base: true
|
||||||
|
|
||||||
|
updates:
|
||||||
|
# npm dependencies (root)
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: production
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
development:
|
||||||
|
dependency-type: development
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
registries:
|
||||||
|
- npm-npmjs
|
||||||
|
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - macOS app
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /apps/macos
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - shared ClawdbotKit
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /apps/shared/ClawdbotKit
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - Swabble
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /Swabble
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Gradle - Android app
|
||||||
|
- package-ecosystem: gradle
|
||||||
|
directory: /apps/android
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
android-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"channel: bluebubbles":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/bluebubbles/**"
|
||||||
|
- "docs/channels/bluebubbles.md"
|
||||||
|
"channel: discord":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/discord/**"
|
||||||
|
- "extensions/discord/**"
|
||||||
|
- "docs/channels/discord.md"
|
||||||
|
"channel: googlechat":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/googlechat/**"
|
||||||
|
- "docs/channels/googlechat.md"
|
||||||
|
"channel: imessage":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/imessage/**"
|
||||||
|
- "extensions/imessage/**"
|
||||||
|
- "docs/channels/imessage.md"
|
||||||
|
"channel: line":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/line/**"
|
||||||
|
- "docs/channels/line.md"
|
||||||
|
"channel: matrix":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/matrix/**"
|
||||||
|
- "docs/channels/matrix.md"
|
||||||
|
"channel: mattermost":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/mattermost/**"
|
||||||
|
- "docs/channels/mattermost.md"
|
||||||
|
"channel: msteams":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/msteams/**"
|
||||||
|
- "docs/channels/msteams.md"
|
||||||
|
"channel: nextcloud-talk":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/nextcloud-talk/**"
|
||||||
|
- "docs/channels/nextcloud-talk.md"
|
||||||
|
"channel: nostr":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/nostr/**"
|
||||||
|
- "docs/channels/nostr.md"
|
||||||
|
"channel: signal":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/signal/**"
|
||||||
|
- "extensions/signal/**"
|
||||||
|
- "docs/channels/signal.md"
|
||||||
|
"channel: slack":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/slack/**"
|
||||||
|
- "extensions/slack/**"
|
||||||
|
- "docs/channels/slack.md"
|
||||||
|
"channel: telegram":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/telegram/**"
|
||||||
|
- "extensions/telegram/**"
|
||||||
|
- "docs/channels/telegram.md"
|
||||||
|
"channel: tlon":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/tlon/**"
|
||||||
|
- "docs/channels/tlon.md"
|
||||||
|
"channel: voice-call":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/voice-call/**"
|
||||||
|
"channel: whatsapp-web":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/web/**"
|
||||||
|
- "extensions/whatsapp/**"
|
||||||
|
- "docs/channels/whatsapp.md"
|
||||||
|
"channel: zalo":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/zalo/**"
|
||||||
|
- "docs/channels/zalo.md"
|
||||||
|
"channel: zalouser":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/zalouser/**"
|
||||||
|
- "docs/channels/zalouser.md"
|
||||||
|
|
||||||
|
"app: android":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/android/**"
|
||||||
|
- "docs/platforms/android.md"
|
||||||
|
"app: ios":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/ios/**"
|
||||||
|
- "docs/platforms/ios.md"
|
||||||
|
"app: macos":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/macos/**"
|
||||||
|
- "docs/platforms/macos.md"
|
||||||
|
- "docs/platforms/mac/**"
|
||||||
|
"app: web-ui":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "ui/**"
|
||||||
|
- "src/gateway/control-ui.ts"
|
||||||
|
- "src/gateway/control-ui-shared.ts"
|
||||||
|
- "src/gateway/protocol/**"
|
||||||
|
- "src/gateway/server-methods/chat.ts"
|
||||||
|
- "src/infra/control-ui-assets.ts"
|
||||||
|
|
||||||
|
"gateway":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/gateway/**"
|
||||||
|
- "src/daemon/**"
|
||||||
|
- "docs/gateway/**"
|
||||||
|
|
||||||
|
"docs":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "docs/**"
|
||||||
|
- "docs.acp.md"
|
||||||
|
|
||||||
|
"cli":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/cli/**"
|
||||||
|
|
||||||
|
"commands":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/commands/**"
|
||||||
|
|
||||||
|
"scripts":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "scripts/**"
|
||||||
|
|
||||||
|
"docker":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "Dockerfile"
|
||||||
|
- "Dockerfile.*"
|
||||||
|
- "docker-compose.yml"
|
||||||
|
- "docker-setup.sh"
|
||||||
|
- ".dockerignore"
|
||||||
|
- "scripts/**/*docker*"
|
||||||
|
- "scripts/**/Dockerfile*"
|
||||||
|
- "scripts/sandbox-*.sh"
|
||||||
|
- "src/agents/sandbox*.ts"
|
||||||
|
- "src/commands/sandbox*.ts"
|
||||||
|
- "src/cli/sandbox-cli.ts"
|
||||||
|
- "src/docker-setup.test.ts"
|
||||||
|
- "src/config/**/*sandbox*"
|
||||||
|
- "docs/cli/sandbox.md"
|
||||||
|
- "docs/gateway/sandbox*.md"
|
||||||
|
- "docs/install/docker.md"
|
||||||
|
- "docs/multi-agent-sandbox-tools.md"
|
||||||
|
|
||||||
|
"agents":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/agents/**"
|
||||||
|
|
||||||
|
"security":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "docs/cli/security.md"
|
||||||
|
- "docs/gateway/security.md"
|
||||||
|
|
||||||
|
"extensions: copilot-proxy":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/copilot-proxy/**"
|
||||||
|
"extensions: diagnostics-otel":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/diagnostics-otel/**"
|
||||||
|
"extensions: google-antigravity-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/google-antigravity-auth/**"
|
||||||
|
"extensions: google-gemini-cli-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/google-gemini-cli-auth/**"
|
||||||
|
"extensions: llm-task":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/llm-task/**"
|
||||||
|
"extensions: lobster":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/lobster/**"
|
||||||
|
"extensions: memory-core":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/memory-core/**"
|
||||||
|
"extensions: memory-lancedb":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/memory-lancedb/**"
|
||||||
|
"extensions: open-prose":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/open-prose/**"
|
||||||
|
"extensions: qwen-portal-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/qwen-portal-auth/**"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Auto response
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
pull_request_target:
|
||||||
|
types: [labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-response:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: "2729701"
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
- name: Handle labeled items
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
label: "skill-clawdhub",
|
||||||
|
close: true,
|
||||||
|
message:
|
||||||
|
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const labelName = context.payload.label?.name;
|
||||||
|
if (!labelName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = rules.find((item) => item.label === labelName);
|
||||||
|
if (!rule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
|
||||||
|
if (!issueNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body: rule.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rule.close) {
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: "closed",
|
||||||
|
});
|
||||||
|
}
|
||||||
+66
-24
@@ -32,20 +32,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies (frozen)
|
- name: Install dependencies (frozen)
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@@ -108,6 +117,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@@ -118,16 +141,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@@ -168,6 +186,8 @@ jobs:
|
|||||||
|
|
||||||
checks-windows:
|
checks-windows:
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -212,6 +232,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@@ -222,16 +256,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@@ -279,20 +308,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@@ -304,6 +342,8 @@ jobs:
|
|||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
- name: Run ${{ matrix.task }}
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
@@ -590,6 +630,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
gradle-version: 8.11.1
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
name: Docker Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Build amd64 image
|
||||||
|
build-amd64:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
image-metadata: ${{ steps.meta.outputs.json }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{version}},suffix=-amd64
|
||||||
|
type=semver,pattern={{version}},suffix=-arm64
|
||||||
|
type=ref,event=branch,suffix=-amd64
|
||||||
|
type=ref,event=branch,suffix=-arm64
|
||||||
|
|
||||||
|
- name: Build and push amd64 image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
push: true
|
||||||
|
|
||||||
|
# Build arm64 image
|
||||||
|
build-arm64:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
image-metadata: ${{ steps.meta.outputs.json }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{version}},suffix=-amd64
|
||||||
|
type=semver,pattern={{version}},suffix=-arm64
|
||||||
|
type=ref,event=branch,suffix=-amd64
|
||||||
|
type=ref,event=branch,suffix=-arm64
|
||||||
|
|
||||||
|
- name: Build and push arm64 image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/arm64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
push: true
|
||||||
|
|
||||||
|
# Create multi-platform manifest
|
||||||
|
create-manifest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
needs: [build-amd64, build-arm64]
|
||||||
|
steps:
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for manifest
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Create and push manifest
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
${{ needs.build-amd64.outputs.image-digest }} \
|
||||||
|
${{ needs.build-arm64.outputs.image-digest }}
|
||||||
|
env:
|
||||||
|
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||||
@@ -13,12 +13,19 @@ jobs:
|
|||||||
- name: Checkout CLI
|
- name: Checkout CLI
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm (corepack retry)
|
||||||
uses: pnpm/action-setup@v3
|
run: |
|
||||||
with:
|
set -euo pipefail
|
||||||
version: 10
|
corepack enable
|
||||||
- name: Enable Corepack
|
for attempt in 1 2 3; do
|
||||||
run: corepack enable
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Install pnpm deps (minimal)
|
- name: Install pnpm deps (minimal)
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||||
@@ -29,5 +36,6 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
|
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: "2729701"
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
- uses: actions/labeler@v5
|
||||||
|
with:
|
||||||
|
configuration-path: .github/labeler.yml
|
||||||
|
repo-token: ${{ steps.app-token.outputs.token }}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Pre-commit hooks for clawdbot
|
||||||
|
# Install: prek install
|
||||||
|
# Run manually: prek run --all-files
|
||||||
|
#
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
|
||||||
|
repos:
|
||||||
|
# Basic file hygiene
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||||
|
- id: check-yaml
|
||||||
|
args: [--allow-multiple-documents]
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=500]
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
# Secret detection (same as CI)
|
||||||
|
- repo: https://github.com/Yelp/detect-secrets
|
||||||
|
rev: v1.5.0
|
||||||
|
hooks:
|
||||||
|
- id: detect-secrets
|
||||||
|
args:
|
||||||
|
- --baseline
|
||||||
|
- .secrets.baseline
|
||||||
|
- --exclude-files
|
||||||
|
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'case \.apiKeyEnv: "API key \(env var\)"'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'case apikey = "apiKey"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"gateway\.remote\.password"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"gateway\.auth\.password"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"talk\.apiKey"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '=== "string"'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'typeof remote\?\.password === "string"'
|
||||||
|
|
||||||
|
# Shell script linting
|
||||||
|
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||||
|
rev: v0.11.0
|
||||||
|
hooks:
|
||||||
|
- id: shellcheck
|
||||||
|
args: [--severity=error] # Only fail on errors, not warnings/info
|
||||||
|
# Exclude vendor and scripts with embedded code or known issues
|
||||||
|
exclude: '^(vendor/|scripts/e2e/)'
|
||||||
|
|
||||||
|
# GitHub Actions linting
|
||||||
|
- repo: https://github.com/rhysd/actionlint
|
||||||
|
rev: v1.7.10
|
||||||
|
hooks:
|
||||||
|
- id: actionlint
|
||||||
|
|
||||||
|
# GitHub Actions security audit
|
||||||
|
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||||
|
rev: v1.22.0
|
||||||
|
hooks:
|
||||||
|
- id: zizmor
|
||||||
|
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
|
||||||
|
exclude: '^(vendor/|Swabble/)'
|
||||||
|
|
||||||
|
# Project checks (same commands as CI)
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
# oxlint --type-aware src test
|
||||||
|
- id: oxlint
|
||||||
|
name: oxlint
|
||||||
|
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types_or: [javascript, jsx, ts, tsx]
|
||||||
|
|
||||||
|
# oxfmt --check src test
|
||||||
|
- id: oxfmt
|
||||||
|
name: oxfmt
|
||||||
|
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types_or: [javascript, jsx, ts, tsx]
|
||||||
|
|
||||||
|
# swiftlint (same as CI)
|
||||||
|
- id: swiftlint
|
||||||
|
name: swiftlint
|
||||||
|
entry: swiftlint --config .swiftlint.yml
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types: [swift]
|
||||||
|
|
||||||
|
# swiftformat --lint (same as CI)
|
||||||
|
- id: swiftformat
|
||||||
|
name: swiftformat
|
||||||
|
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types: [swift]
|
||||||
+1798
-125
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
# ShellCheck configuration
|
||||||
|
# https://www.shellcheck.net/wiki/
|
||||||
|
|
||||||
|
# Disable common false positives and style suggestions
|
||||||
|
|
||||||
|
# SC2034: Variable appears unused (often exported or used indirectly)
|
||||||
|
disable=SC2034
|
||||||
|
|
||||||
|
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
||||||
|
disable=SC2155
|
||||||
|
|
||||||
|
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
||||||
|
disable=SC2295
|
||||||
|
|
||||||
|
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
||||||
|
disable=SC1012
|
||||||
|
|
||||||
|
# SC2026: Word outside quotes (info-level, often intentional)
|
||||||
|
disable=SC2026
|
||||||
|
|
||||||
|
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
||||||
|
disable=SC2016
|
||||||
|
|
||||||
|
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
||||||
|
disable=SC2129
|
||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
# Whitespace
|
# Whitespace
|
||||||
--trimwhitespace always
|
--trimwhitespace always
|
||||||
--emptybraces no-space
|
--emptybraces no-space
|
||||||
--nospaceoperators ...,..<
|
--nospaceoperators ...,..<
|
||||||
--ranges no-space
|
--ranges no-space
|
||||||
--someAny true
|
--someAny true
|
||||||
--voidtype void
|
--voidtype void
|
||||||
|
|||||||
@@ -7,28 +7,43 @@
|
|||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||||
|
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||||
- Core channel docs: `docs/channels/`
|
- Core channel docs: `docs/channels/`
|
||||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||||
|
- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
## Docs Linking (Mintlify)
|
||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||||
|
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
|
## exe.dev VM ops (general)
|
||||||
|
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||||
|
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
||||||
|
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||||
|
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
|
||||||
|
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
||||||
|
- Restart: stop old gateway and run:
|
||||||
|
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
||||||
|
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
|
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
- 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)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
@@ -50,6 +65,7 @@
|
|||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
|
- Do not set test workers above 16; tried already.
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
- Full kit + what’s covered: `docs/testing.md`.
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
@@ -64,6 +80,7 @@
|
|||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
||||||
- 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`.
|
- 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.
|
- 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.
|
||||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||||
@@ -92,6 +109,7 @@
|
|||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Vocabulary: "makeup" = "mac app".
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||||
|
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
@@ -100,7 +118,7 @@
|
|||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- 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.
|
- 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.**
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -117,6 +135,10 @@
|
|||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||||
|
- Lint/format churn:
|
||||||
|
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||||
|
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||||
|
- Only ask when changes are semantic (logic/data/behavior).
|
||||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
||||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
|
|||||||
+472
-249
@@ -2,274 +2,497 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.25
|
||||||
|
Status: unreleased.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
|
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
|
||||||
|
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
||||||
|
- Docs: add migration guide for moving to a new machine. (#2381)
|
||||||
|
- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN.
|
||||||
|
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
||||||
|
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||||
|
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||||
|
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||||
|
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
||||||
|
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
|
||||||
|
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
|
||||||
|
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
|
||||||
|
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
|
||||||
|
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
|
||||||
|
- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank.
|
||||||
|
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
|
||||||
|
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
|
||||||
|
- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
|
||||||
|
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
||||||
|
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
|
||||||
|
- Onboarding: strengthen security warning copy for beta + access control expectations.
|
||||||
|
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
|
||||||
|
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
|
||||||
|
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
|
||||||
|
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
||||||
|
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
|
||||||
|
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
||||||
|
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
||||||
|
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
|
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||||
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
|
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
|
||||||
|
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
||||||
|
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||||
|
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
||||||
|
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||||
|
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||||
|
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
|
||||||
|
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||||
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
|
||||||
|
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
|
||||||
|
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
||||||
|
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
|
||||||
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
|
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
|
||||||
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
|
- LINE: use constant-time signature validation for webhook verification. (#2475) Thanks @Kiwitwitter.
|
||||||
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
|
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
||||||
|
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
||||||
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
|
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
|
||||||
|
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||||
|
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
|
||||||
|
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||||
|
|
||||||
|
## 2026.1.24-3
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen.
|
||||||
|
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
|
||||||
|
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
|
||||||
|
- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
|
||||||
|
|
||||||
|
## 2026.1.24-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/link-understanding output in npm tarball (fixes missing apply.js import on install).
|
||||||
|
|
||||||
|
## 2026.1.24-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).
|
||||||
|
|
||||||
|
## 2026.1.24
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
|
||||||
|
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
|
||||||
|
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||||
|
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||||
|
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
|
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
|
||||||
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||||
|
- Docs: add verbose installer troubleshooting guidance.
|
||||||
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
|
- Docs: update Fly.io guide notes.
|
||||||
|
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||||
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
|
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||||
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
|
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||||
|
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||||
|
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||||
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
|
- iMessage: normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively and keep service-prefixed handles stable. (#1708) Thanks @aaronn.
|
||||||
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
|
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||||
|
- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.
|
||||||
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
|
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
|
||||||
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
|
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||||
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204.
|
||||||
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
|
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
|
||||||
|
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||||
|
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||||
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
|
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||||
|
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||||
|
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||||
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
|
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
||||||
|
|
||||||
|
## 2026.1.23
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
|
||||||
|
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
|
||||||
|
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
|
||||||
|
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
|
||||||
|
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
|
||||||
|
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
|
||||||
|
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
|
||||||
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
|
||||||
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
|
||||||
|
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
|
||||||
|
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
|
||||||
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
|
||||||
|
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||||
|
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
|
||||||
|
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||||
|
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
||||||
|
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
||||||
|
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||||
|
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||||
|
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||||
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
|
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
|
||||||
|
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
|
||||||
|
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||||
|
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||||
|
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||||
|
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
|
||||||
|
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
|
||||||
|
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||||
|
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||||
|
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||||
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
|
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
||||||
|
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||||
|
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||||
|
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
||||||
|
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
|
||||||
|
- Telegram: render markdown in media captions. (#1478)
|
||||||
|
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
|
||||||
|
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||||
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
|
||||||
|
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||||
|
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
|
||||||
|
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
|
||||||
|
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
|
||||||
|
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||||
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
|
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||||
|
|
||||||
|
## 2026.1.22
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
|
||||||
|
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
|
||||||
|
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||||
|
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||||
|
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- 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.
|
||||||
|
- Gateway: stop the service before uninstalling and fail if it remains loaded.
|
||||||
|
- 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.
|
||||||
|
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
## 2026.1.21-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- 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.21
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
|
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||||
|
- 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.
|
||||||
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
|
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||||
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
|
- 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
|
## 2026.1.20
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Deps: update workspace + memory-lancedb dependencies.
|
- Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui
|
||||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
- 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`.
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
|
||||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
|
||||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
|
||||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
|
||||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
|
||||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
|
||||||
### Fixes
|
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
|
||||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
|
||||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
|
||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
|
||||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
|
||||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
|
||||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
|
||||||
- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot.
|
|
||||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
|
||||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
|
||||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
|
||||||
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
|
||||||
- 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)
|
|
||||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
|
||||||
|
|
||||||
## 2026.1.19-3
|
- 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)
|
||||||
### Changes
|
- Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs
|
||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
- Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security
|
||||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
- Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec
|
||||||
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec
|
||||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
- 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
|
||||||
### Fixes
|
- Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node
|
||||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
- 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
|
||||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
- Sessions: allow `sessions_spawn` to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents
|
||||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
- 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
|
||||||
## 2026.1.19-2
|
- 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
|
||||||
### Changes
|
- Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android
|
||||||
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock
|
||||||
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
- Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp
|
||||||
- Docs: refresh Android node discovery docs for the Gateway WS service type.
|
- 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
|
||||||
### Fixes
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||||
|
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||||
### Fixes
|
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||||
- Agents: add `clawdbot agents set-identity` helper and update bootstrap guidance for multi-agent setups. (#1222) — thanks @ThePickle31.
|
- Android: bump okhttp + dnsjava to satisfy lint dependency checks.
|
||||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
- Build: update workspace + core/plugin deps.
|
||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
- Build: use tsgo for dev/watch builds by default (opt out with `CLAWDBOT_TS_COMPILER=tsc`).
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
- 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.
|
|
||||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## 2026.1.18-4
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||||
- macOS: stop syncing Peekaboo in postinstall.
|
- macOS: stop syncing Peekaboo in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- 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.
|
### Breaking
|
||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
- **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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- Sessions: fall back to session labels when listing display names. (#1124)
|
||||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
- 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: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#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.
|
- 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: parallelize embedding indexing with rate-limit retries.
|
||||||
- Memory: split overly long lines to keep embeddings under token limits.
|
- Memory: split overly long lines to keep embeddings under token limits.
|
||||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
- 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.
|
- 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
|
## 2026.1.16-2
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,13 @@ Please include in your PR:
|
|||||||
- [ ] Confirm you understand what the code does
|
- [ ] Confirm you understand what the code does
|
||||||
|
|
||||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||||
|
|
||||||
|
## Current Focus & Roadmap 🗺
|
||||||
|
|
||||||
|
We are currently prioritizing:
|
||||||
|
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||||
|
- **UX**: Improving the onboarding wizard and error messages.
|
||||||
|
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
||||||
|
- **Performance**: Optimizing token usage and compaction logic.
|
||||||
|
|
||||||
|
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
||||||
@@ -32,4 +32,9 @@ RUN pnpm ui:build
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Security hardening: Run as non-root user
|
||||||
|
# The node:22-bookworm image includes a 'node' user (uid 1000)
|
||||||
|
# This reduces the attack surface by preventing container escape via root privileges
|
||||||
|
USER node
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
|
|||||||
# Send a message
|
# Send a message
|
||||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
|||||||
|
|
||||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||||
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||||
@@ -138,7 +138,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Google Chat](https://docs.clawd.bot/channels/googlechat) (Chat API), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||||
|
|
||||||
### Apps + nodes
|
### Apps + nodes
|
||||||
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────┐
|
||||||
@@ -252,7 +252,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
|||||||
|
|
||||||
## Chat commands
|
## Chat commands
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||||
|
|
||||||
- `/status` — compact session status (model + tokens, cost when available)
|
- `/status` — compact session status (model + tokens, cost when available)
|
||||||
- `/new` or `/reset` — reset the session
|
- `/new` or `/reset` — reset the session
|
||||||
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
|||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||||
by Peter Steinberger and the community.
|
by Peter Steinberger and the community.
|
||||||
|
|
||||||
- [clawd.me](https://clawd.me)
|
- [clawd.me](https://clawd.me)
|
||||||
@@ -468,38 +468,45 @@ by Peter Steinberger and the community.
|
|||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||||
|
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||||
Core contributors:
|
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<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/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></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/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></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/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></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/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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></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/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/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></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/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/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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/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/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/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/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/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/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></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/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></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/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/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/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/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></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/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/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/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/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/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/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/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/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/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/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/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/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/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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/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/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/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/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></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/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/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></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/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/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/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/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/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></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/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></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/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/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/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/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/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/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/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/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/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/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/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/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/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/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=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/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/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=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/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/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></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/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/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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=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/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/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/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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></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=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/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=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/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></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/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/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></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/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></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/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/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/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></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/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/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/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/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></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/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></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/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></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/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/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></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/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></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/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></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/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></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/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a>
|
||||||
|
<a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></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/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></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=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/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>
|
</p>
|
||||||
+43
-1
@@ -1,6 +1,6 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
If you believe you've found a security issue in Clawdbot, please report it privately.
|
||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
@@ -13,3 +13,45 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
|
|||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
|
## Runtime Requirements
|
||||||
|
|
||||||
|
### Node.js Version
|
||||||
|
|
||||||
|
Clawdbot requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
|
||||||
|
|
||||||
|
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||||
|
- CVE-2026-21636: Permission model bypass vulnerability
|
||||||
|
|
||||||
|
Verify your Node.js version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version # Should be v22.12.0 or later
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
|
||||||
|
When running Clawdbot in Docker:
|
||||||
|
|
||||||
|
1. The official image runs as a non-root user (`node`) for reduced attack surface
|
||||||
|
2. Use `--read-only` flag when possible for additional filesystem protection
|
||||||
|
3. Limit container capabilities with `--cap-drop=ALL`
|
||||||
|
|
||||||
|
Example secure Docker run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --read-only --cap-drop=ALL \
|
||||||
|
-v clawdbot-data:/app/data \
|
||||||
|
clawdbot/clawdbot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Scanning
|
||||||
|
|
||||||
|
This project uses `detect-secrets` for automated secret detection in CI/CD.
|
||||||
|
See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline.
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install detect-secrets==1.5.0
|
||||||
|
detect-secrets scan --baseline .secrets.baseline
|
||||||
|
```
|
||||||
+144
-231
@@ -3,273 +3,186 @@
|
|||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.16-2</title>
|
<title>2026.1.24-1</title>
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
<pubDate>Sun, 25 Jan 2026 14:05:25 +0000</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>6273</sparkle:version>
|
<sparkle:version>7952</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.24-1</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.24-1</h2>
|
||||||
<h3>Changes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
<li>Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></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=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.24-1/Clawdbot-2026.1.24-1.zip" length="12396699" type="application/octet-stream" sparkle:edSignature="VaEdWIgEJBrZLIp2UmigoQ6vaq4P/jNFXpHYXvXHD5MsATS0CqBl6ugyyxRq+/GbpUqmdgdlht4dTUVbLRw6BA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.15</title>
|
<title>2026.1.24</title>
|
||||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
<pubDate>Sun, 25 Jan 2026 13:31:05 +0000</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>5998</sparkle:version>
|
<sparkle:version>7944</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.24</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.24</h2>
|
||||||
<h3>Highlights</h3>
|
<h3>Highlights</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
<li>Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice</li>
|
||||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
<li>Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.</li>
|
||||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
<li>TTS: Edge fallback (keyless) + <code>/tts</code> auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts</li>
|
||||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
<li>Exec approvals: approve in-chat via <code>/approve</code> across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||||
</ul>
|
<li>Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||||
<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>
|
</ul>
|
||||||
<h3>Changes</h3>
|
<h3>Changes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
<li>Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.</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>TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts</li>
|
||||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
<li>TTS: add auto mode enum (off/always/inbound/tagged) with per-session <code>/tts</code> override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts</li>
|
||||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
<li>Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.</li>
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
<li>Telegram: add <code>channels.telegram.linkPreview</code> to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
<li>Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web</li>
|
||||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
<li>UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.</li>
|
||||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
<li>Exec approvals: forward approval prompts to chat with <code>/approve</code> for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
<li>Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.</li>
|
||||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
<li>Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags</li>
|
||||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
<li>Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).</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: add verbose installer troubleshooting guidance.</li>
|
||||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
<li>Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.</li>
|
||||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
<li>Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock</li>
|
||||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
<li>Docs: update Fly.io guide notes.</li>
|
||||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
<li>Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.</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>
|
</ul>
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
<li>Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.</li>
|
||||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
<li>Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.</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>Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.</li>
|
||||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
<li>Web UI: hide internal <code>message_id</code> hints in chat bubbles.</li>
|
||||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
<li>Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (<code>gateway.controlUi.allowInsecureAuth</code>). (#1679) Thanks @steipete.</li>
|
||||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
<li>Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.</li>
|
||||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
<li>BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles</li>
|
||||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
<li>BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.</li>
|
||||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
<li>Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.</li>
|
||||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
<li>Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal</li>
|
||||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
<li>Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.</li>
|
||||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
<li>Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)</li>
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
<li>Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.</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>Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.</li>
|
||||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
<li>Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)</li>
|
||||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
<li>Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.</li>
|
||||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
<li>Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.</li>
|
||||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
<li>Google Chat: normalize space targets without double <code>spaces/</code> prefix.</li>
|
||||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
<li>Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.</li>
|
||||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
<li>Agents: use the active auth profile for auto-compaction recovery.</li>
|
||||||
|
<li>Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.</li>
|
||||||
|
<li>Models: default missing custom provider fields so minimal configs are accepted.</li>
|
||||||
|
<li>Messaging: keep newline chunking safe for fenced markdown blocks across channels.</li>
|
||||||
|
<li>TUI: reload history after gateway reconnect to restore session state. (#1663)</li>
|
||||||
|
<li>Heartbeat: normalize target identifiers for consistent routing.</li>
|
||||||
|
<li>Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.</li>
|
||||||
|
<li>Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.</li>
|
||||||
|
<li>Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.</li>
|
||||||
|
<li>Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)</li>
|
||||||
|
<li>Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.</li>
|
||||||
|
<li>Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)</li>
|
||||||
|
<li>Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.</li>
|
||||||
|
<li>Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)</li>
|
||||||
|
<li>macOS: default direct-transport <code>ws://</code> URLs to port 18789; document <code>gateway.remote.transport</code>. (#1603) Thanks @ngutman.</li>
|
||||||
|
<li>Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.</li>
|
||||||
|
<li>Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
||||||
|
<li>Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></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=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.24/Clawdbot-2026.1.24.zip" length="12396700" type="application/octet-stream" sparkle:edSignature="u+XzKD3YwV8s79gIr7LK4OtDCcmp/b+cjNC6SHav3/1CVJegh02SsBKatrampox32XGx8P2+8c/+fHV+qpkHCA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.14-1</title>
|
<title>2026.1.23</title>
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>5825</sparkle:version>
|
<sparkle:version>7750</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
|
||||||
<h3>Highlights</h3>
|
<h3>Highlights</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</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>
|
</ul>
|
||||||
<h3>Changes</h3>
|
<h3>Changes</h3>
|
||||||
<h4>Web Tools</h4>
|
|
||||||
<ul>
|
<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>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
||||||
</ul>
|
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
||||||
<h4>Browser / Control UI</h4>
|
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
||||||
<ul>
|
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</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>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
||||||
</ul>
|
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
||||||
<h4>Plugins</h4>
|
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
||||||
<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>
|
</ul>
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</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>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
<li>Sessions: normalize session key casing to lowercase for consistent routing.</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>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</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>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
||||||
</ul>
|
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
||||||
<h4>CLI / Onboarding</h4>
|
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
||||||
<ul>
|
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
||||||
</ul>
|
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
||||||
<h4>Control UI / TUI</h4>
|
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
||||||
<ul>
|
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
<li>Telegram: render markdown in media captions. (#1478)</li>
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
||||||
</ul>
|
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
||||||
<ul>
|
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</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>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></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=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601200
|
versionCode = 202601250
|
||||||
versionName = "2026.1.20"
|
versionName = "2026.1.25"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -12,4 +12,3 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
|||||||
caps = buildCapabilities(),
|
caps = buildCapabilities(),
|
||||||
commands = buildInvokeCommands(),
|
commands = buildInvokeCommands(),
|
||||||
permissions = emptyMap(),
|
permissions = emptyMap(),
|
||||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||||
userAgent = buildUserAgent(),
|
userAgent = buildUserAgent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,10 +8,14 @@ object WakeWords {
|
|||||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||||
|
val parsed = parseCommaSeparated(input)
|
||||||
|
return if (parsed == current) null else parsed
|
||||||
|
}
|
||||||
|
|
||||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
val cleaned =
|
val cleaned =
|
||||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
return cleaned.ifEmpty { defaults }
|
return cleaned.ifEmpty { defaults }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an SMS message.
|
* Send an SMS message.
|
||||||
*
|
*
|
||||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||||
* @return SendResult indicating success or failure
|
* @return SendResult indicating success or failure
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
|
|||||||
import com.clawdbot.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdbot.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
|
import com.clawdbot.android.WakeWords
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||||
val deviceModel =
|
val deviceModel =
|
||||||
remember {
|
remember {
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
val commitWakeWords = {
|
||||||
|
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||||
|
if (parsed != null) {
|
||||||
|
viewModel.setWakeWords(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
value = wakeWordsText,
|
value = wakeWordsText,
|
||||||
onValueChange = setWakeWordsText,
|
onValueChange = setWakeWordsText,
|
||||||
label = { Text("Wake Words (comma-separated)") },
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
wakeWordsHadFocus = true
|
||||||
|
} else if (wakeWordsHadFocus) {
|
||||||
|
wakeWordsHadFocus = false
|
||||||
|
commitWakeWords()
|
||||||
|
}
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
commitWakeWords()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
|
||||||
viewModel.setWakeWords(parsed)
|
|
||||||
},
|
|
||||||
enabled = isConnected,
|
|
||||||
) {
|
|
||||||
Text("Save + Sync")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WakeWordsTest {
|
class WakeWordsTest {
|
||||||
@@ -32,5 +33,18 @@ class WakeWordsTest {
|
|||||||
assertEquals("w1", sanitized.first())
|
assertEquals("w1", sanitized.first())
|
||||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedSkipsWhenUnchanged() {
|
||||||
|
val current = listOf("clawd", "claude")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
|
||||||
|
assertNull(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedReturnsUpdatedList() {
|
||||||
|
val current = listOf("clawd")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
|
||||||
|
assertEquals(listOf("clawd", "jarvis"), parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
-1
@@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
@@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.20</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260120</string>
|
<string>20260125</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
@FocusState private var focusedTriggerIndex: Int?
|
||||||
@State private var syncTask: Task<Void, Never>?
|
@State private var syncTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
TextField("Wake word", text: self.binding(for: index))
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.focused(self.$focusedTriggerIndex, equals: index)
|
||||||
|
.onSubmit {
|
||||||
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: self.removeWords)
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.triggerWords) { _, newValue in
|
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
||||||
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
guard oldValue != nil, oldValue != newValue else { return }
|
||||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||||
self.syncTask?.cancel()
|
guard self.focusedTriggerIndex == nil else { return }
|
||||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
let updated = VoiceWakePreferences.loadTriggerWords()
|
||||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
if updated != self.triggerWords {
|
||||||
await appModel?.setGlobalWakeWords(snapshot)
|
self.triggerWords = updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
self.triggerWords[index] = newValue
|
self.triggerWords[index] = newValue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func commitTriggerWords() {
|
||||||
|
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
|
||||||
|
|
||||||
|
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
||||||
|
self.syncTask?.cancel()
|
||||||
|
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||||
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||||
|
await appModel?.setGlobalWakeWords(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startRecognition() throws {
|
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.stopRecognition()
|
||||||
self.speechRecognizer = SFSpeechRecognizer()
|
self.speechRecognizer = SFSpeechRecognizer()
|
||||||
guard let recognizer = self.speechRecognizer else {
|
guard let recognizer = self.speechRecognizer else {
|
||||||
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
let input = self.audioEngine.inputNode
|
let input = self.audioEngine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
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)
|
input.removeTap(onBus: 0)
|
||||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
|
|||||||
|
|
||||||
// Keep defaults aligned with the mac app.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||||
|
static let maxWords = 32
|
||||||
|
static let maxWordLength = 64
|
||||||
|
|
||||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||||
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(Self.maxWords)
|
||||||
|
.map { String($0.prefix(Self.maxWordLength)) }
|
||||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.20</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260120</string>
|
<string>20260125</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -11,6 +11,18 @@ import Testing
|
|||||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordLength() {
|
||||||
|
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
|
||||||
|
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordCount() {
|
||||||
|
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||||
|
#expect(cleaned.count == VoiceWakePreferences.maxWords)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func displayStringUsesSanitizedWords() {
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.20"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260120"
|
CFBundleVersion: "20260125"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.20"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260120"
|
CFBundleVersion: "20260125"
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
],
|
],
|
||||||
"squares" : "shared"
|
"squares" : "shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "550d4ea41d4bb2546b99a7bfa1c5cba7e28a13862bc226727ea7426c61555a33",
|
"originHash" : "f847d54db16b371dbb1a79271d50436cdec572179b0f0cf14cfe1b75df8dfbc2",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "axorcist",
|
"identity" : "axorcist",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -123,8 +123,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/gonzalezreal/textual",
|
"location" : "https://github.com/gonzalezreal/textual",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||||
"version" : "0.2.0"
|
"version" : "0.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ final class AppState {
|
|||||||
case remote
|
case remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RemoteTransport: String {
|
||||||
|
case ssh
|
||||||
|
case direct
|
||||||
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
var isPaused: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||||
}
|
}
|
||||||
@@ -166,6 +171,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remoteTransport: RemoteTransport {
|
||||||
|
didSet { self.syncGatewayConfigIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
var canvasEnabled: Bool {
|
var canvasEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
@@ -200,6 +209,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remoteUrl: String {
|
||||||
|
didSet { self.syncGatewayConfigIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
var remoteIdentity: String {
|
var remoteIdentity: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||||
}
|
}
|
||||||
@@ -263,13 +276,15 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||||
|
self.remoteTransport = configRemoteTransport
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
if resolvedConnectionMode == .remote,
|
if resolvedConnectionMode == .remote,
|
||||||
|
configRemoteTransport != .direct,
|
||||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||||
{
|
{
|
||||||
@@ -277,6 +292,7 @@ final class AppState {
|
|||||||
} else {
|
} else {
|
||||||
self.remoteTarget = storedRemoteTarget
|
self.remoteTarget = storedRemoteTarget
|
||||||
}
|
}
|
||||||
|
self.remoteUrl = configRemoteUrl ?? ""
|
||||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
@@ -354,10 +370,11 @@ final class AppState {
|
|||||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||||
let gateway = root["gateway"] as? [String: Any]
|
let gateway = root["gateway"] as? [String: Any]
|
||||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||||
let hasRemoteUrl = !(remoteUrl?
|
let hasRemoteUrl = !(remoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||||
case "local":
|
case "local":
|
||||||
@@ -378,8 +395,17 @@ final class AppState {
|
|||||||
self.connectionMode = .remote
|
self.connectionMode = .remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if remoteTransport != self.remoteTransport {
|
||||||
|
self.remoteTransport = remoteTransport
|
||||||
|
}
|
||||||
|
let remoteUrlText = remoteUrl ?? ""
|
||||||
|
if remoteUrlText != self.remoteUrl {
|
||||||
|
self.remoteUrl = remoteUrlText
|
||||||
|
}
|
||||||
|
|
||||||
let targetMode = desiredMode ?? self.connectionMode
|
let targetMode = desiredMode ?? self.connectionMode
|
||||||
if targetMode == .remote,
|
if targetMode == .remote,
|
||||||
|
remoteTransport != .direct,
|
||||||
let host = AppState.remoteHost(from: remoteUrl)
|
let host = AppState.remoteHost(from: remoteUrl)
|
||||||
{
|
{
|
||||||
self.updateRemoteTarget(host: host)
|
self.updateRemoteTarget(host: host)
|
||||||
@@ -387,10 +413,17 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateRemoteTarget(host: String) {
|
private func updateRemoteTarget(host: String) {
|
||||||
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
|
let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let user = parsed?.user ?? NSUserName()
|
guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
|
||||||
let port = parsed?.port ?? 22
|
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
|
||||||
|
let port = parsed.port
|
||||||
|
let assembled: String
|
||||||
|
if let user {
|
||||||
|
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||||
|
} else {
|
||||||
|
assembled = port == 22 ? host : "\(host):\(port)"
|
||||||
|
}
|
||||||
if assembled != self.remoteTarget {
|
if assembled != self.remoteTarget {
|
||||||
self.remoteTarget = assembled
|
self.remoteTarget = assembled
|
||||||
}
|
}
|
||||||
@@ -402,6 +435,8 @@ final class AppState {
|
|||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
let remoteIdentity = self.remoteIdentity
|
||||||
|
let remoteTransport = self.remoteTransport
|
||||||
|
let remoteUrl = self.remoteUrl
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@@ -435,39 +470,63 @@ final class AppState {
|
|||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
var remoteChanged = false
|
||||||
|
|
||||||
if let host = remoteHost {
|
if remoteTransport == .direct {
|
||||||
let existingUrl = (remote["url"] as? String)?
|
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
if trimmedUrl.isEmpty {
|
||||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
if remote["url"] != nil {
|
||||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
remote.removeValue(forKey: "url")
|
||||||
let port = parsedExisting?.port ?? 18789
|
remoteChanged = true
|
||||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
}
|
||||||
if existingUrl != desiredUrl {
|
} else {
|
||||||
remote["url"] = desiredUrl
|
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||||
|
if (remote["url"] as? String) != normalizedUrl {
|
||||||
|
remote["url"] = normalizedUrl
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||||
|
remote["transport"] = RemoteTransport.direct.rawValue
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
if remote["transport"] != nil {
|
||||||
|
remote.removeValue(forKey: "transport")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
if let host = remoteHost {
|
||||||
|
let existingUrl = (remote["url"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||||
|
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||||
|
let port = parsedExisting?.port ?? 18789
|
||||||
|
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||||
|
if existingUrl != desiredUrl {
|
||||||
|
remote["url"] = desiredUrl
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||||
if !sanitizedTarget.isEmpty {
|
if !sanitizedTarget.isEmpty {
|
||||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||||
remote["sshTarget"] = sanitizedTarget
|
remote["sshTarget"] = sanitizedTarget
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshTarget"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshTarget")
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
} else if remote["sshTarget"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshTarget")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedIdentity.isEmpty {
|
if !trimmedIdentity.isEmpty {
|
||||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||||
remote["sshIdentity"] = trimmedIdentity
|
remote["sshIdentity"] = trimmedIdentity
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else if remote["sshIdentity"] != nil {
|
||||||
|
remote.removeValue(forKey: "sshIdentity")
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
} else if remote["sshIdentity"] != nil {
|
|
||||||
remote.removeValue(forKey: "sshIdentity")
|
|
||||||
remoteChanged = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if remoteChanged {
|
if remoteChanged {
|
||||||
@@ -621,8 +680,10 @@ extension AppState {
|
|||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
state.connectionMode = .local
|
state.connectionMode = .local
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.canvasEnabled = true
|
state.canvasEnabled = true
|
||||||
state.remoteTarget = "user@example.com"
|
state.remoteTarget = "user@example.com"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||||
state.remoteCliPath = ""
|
state.remoteCliPath = ""
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ extension ChannelsSettings {
|
|||||||
return .orange
|
return .orange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatTint: Color {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return .secondary }
|
||||||
|
if !status.configured { return .secondary }
|
||||||
|
if status.lastError != nil { return .orange }
|
||||||
|
if status.probe?.ok == false { return .orange }
|
||||||
|
if status.running { return .green }
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
|
||||||
var signalTint: Color {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
@@ -85,6 +95,14 @@ extension ChannelsSettings {
|
|||||||
return "Configured"
|
return "Configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatSummary: String {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return "Checking…" }
|
||||||
|
if !status.configured { return "Not configured" }
|
||||||
|
if status.running { return "Running" }
|
||||||
|
return "Configured"
|
||||||
|
}
|
||||||
|
|
||||||
var signalSummary: String {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
@@ -193,6 +211,37 @@ extension ChannelsSettings {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatDetails: String? {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return nil }
|
||||||
|
var lines: [String] = []
|
||||||
|
if let source = status.credentialSource {
|
||||||
|
lines.append("Credential: \(source)")
|
||||||
|
}
|
||||||
|
if let audienceType = status.audienceType {
|
||||||
|
let audience = status.audience ?? ""
|
||||||
|
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||||
|
lines.append("Audience: \(label)")
|
||||||
|
}
|
||||||
|
if let probe = status.probe {
|
||||||
|
if probe.ok {
|
||||||
|
if let elapsed = probe.elapsedMs {
|
||||||
|
lines.append("Probe \(Int(elapsed))ms")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let code = probe.status.map { String($0) } ?? "unknown"
|
||||||
|
lines.append("Probe failed (\(code))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||||
|
lines.append("Last probe \(relativeAge(from: last))")
|
||||||
|
}
|
||||||
|
if let err = status.lastError, !err.isEmpty {
|
||||||
|
lines.append("Error: \(err)")
|
||||||
|
}
|
||||||
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
var signalDetails: String? {
|
var signalDetails: String? {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
@@ -244,7 +293,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||||
let channels = order.enumerated().map { index, id in
|
let channels = order.enumerated().map { index, id in
|
||||||
ChannelItem(
|
ChannelItem(
|
||||||
@@ -307,6 +356,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramTint
|
return self.telegramTint
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordTint
|
return self.discordTint
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatTint
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalTint
|
return self.signalTint
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -326,6 +377,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramSummary
|
return self.telegramSummary
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordSummary
|
return self.discordSummary
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatSummary
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalSummary
|
return self.signalSummary
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -345,6 +398,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramDetails
|
return self.telegramDetails
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordDetails
|
return self.discordDetails
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatDetails
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalDetails
|
return self.signalDetails
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@@ -377,6 +432,10 @@ extension ChannelsSettings {
|
|||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
|
case "googlechat":
|
||||||
|
return self
|
||||||
|
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
|
||||||
|
.lastProbeAt)
|
||||||
case "signal":
|
case "signal":
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
@@ -411,6 +470,10 @@ extension ChannelsSettings {
|
|||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
|
case "googlechat":
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "signal":
|
case "signal":
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|||||||
@@ -85,6 +85,28 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
let lastProbeAt: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GoogleChatProbe: Codable {
|
||||||
|
let ok: Bool
|
||||||
|
let status: Int?
|
||||||
|
let error: String?
|
||||||
|
let elapsedMs: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GoogleChatStatus: Codable {
|
||||||
|
let configured: Bool
|
||||||
|
let credentialSource: String?
|
||||||
|
let audienceType: String?
|
||||||
|
let audience: String?
|
||||||
|
let webhookPath: String?
|
||||||
|
let webhookUrl: String?
|
||||||
|
let running: Bool
|
||||||
|
let lastStartAt: Double?
|
||||||
|
let lastStopAt: Double?
|
||||||
|
let lastError: String?
|
||||||
|
let probe: GoogleChatProbe?
|
||||||
|
let lastProbeAt: Double?
|
||||||
|
}
|
||||||
|
|
||||||
struct SignalProbe: Codable {
|
struct SignalProbe: Codable {
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
let status: Int?
|
let status: Int?
|
||||||
|
|||||||
@@ -282,19 +282,6 @@ enum CommandResolver {
|
|||||||
guard !settings.target.isEmpty else { return nil }
|
guard !settings.target.isEmpty else { return nil }
|
||||||
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||||
|
|
||||||
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 userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
|
||||||
args.append(userHost)
|
|
||||||
|
|
||||||
// Run the real clawdbot CLI on the remote host.
|
// Run the real clawdbot CLI on the remote host.
|
||||||
let exportedPath = [
|
let exportedPath = [
|
||||||
"/opt/homebrew/bin",
|
"/opt/homebrew/bin",
|
||||||
@@ -321,7 +308,7 @@ enum CommandResolver {
|
|||||||
} else {
|
} else {
|
||||||
"""
|
"""
|
||||||
PRJ=\(self.shellQuote(userPRJ))
|
PRJ=\(self.shellQuote(userPRJ))
|
||||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +362,16 @@ enum CommandResolver {
|
|||||||
echo "clawdbot CLI missing on remote host"; exit 127;
|
echo "clawdbot CLI missing on remote host"; exit 127;
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
let options: [String] = [
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UpdateHostKeys=yes",
|
||||||
|
]
|
||||||
|
let args = self.sshArguments(
|
||||||
|
target: parsed,
|
||||||
|
identity: settings.identity,
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["/bin/sh", "-c", scriptBody])
|
||||||
return ["/usr/bin/ssh"] + args
|
return ["/usr/bin/ssh"] + args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,8 +420,11 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.normalizeSSHTargetInput(target)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let userHostPort: String
|
let userHostPort: String
|
||||||
let user: String?
|
let user: String?
|
||||||
if let atRange = trimmed.range(of: "@") {
|
if let atRange = trimmed.range(of: "@") {
|
||||||
@@ -441,13 +440,31 @@ enum CommandResolver {
|
|||||||
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
||||||
host = String(userHostPort[..<colon])
|
host = String(userHostPort[..<colon])
|
||||||
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
||||||
port = Int(portStr) ?? 22
|
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
port = parsedPort
|
||||||
} else {
|
} else {
|
||||||
host = userHostPort
|
host = userHostPort
|
||||||
port = 22
|
port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
return SSHParsedTarget(user: user, host: host, port: port)
|
return self.makeSSHTarget(user: user, host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sshTargetValidationMessage(_ target: String) -> String? {
|
||||||
|
let trimmed = self.normalizeSSHTargetInput(target)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.hasPrefix("-") {
|
||||||
|
return "SSH target cannot start with '-'"
|
||||||
|
}
|
||||||
|
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||||
|
return "SSH target cannot contain spaces"
|
||||||
|
}
|
||||||
|
if self.parseSSHTarget(trimmed) == nil {
|
||||||
|
return "SSH target must look like user@host[:port]"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shellQuote(_ text: String) -> String {
|
private static func shellQuote(_ text: String) -> String {
|
||||||
@@ -465,6 +482,64 @@ enum CommandResolver {
|
|||||||
return URL(fileURLWithPath: expanded)
|
return URL(fileURLWithPath: expanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizeSSHTargetInput(_ target: String) -> String {
|
||||||
|
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("ssh ") {
|
||||||
|
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
|
||||||
|
if value.isEmpty { return false }
|
||||||
|
if !allowLeadingDash, value.hasPrefix("-") { return false }
|
||||||
|
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
|
||||||
|
return value.rangeOfCharacter(from: invalid) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard self.isValidSSHComponent(trimmedHost) else { return nil }
|
||||||
|
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedUser: String?
|
||||||
|
if let trimmedUser {
|
||||||
|
guard self.isValidSSHComponent(trimmedUser) else { return nil }
|
||||||
|
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
|
||||||
|
} else {
|
||||||
|
normalizedUser = nil
|
||||||
|
}
|
||||||
|
guard port > 0, port <= 65535 else { return nil }
|
||||||
|
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
|
||||||
|
target.user.map { "\($0)@\(target.host)" } ?? target.host
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sshArguments(
|
||||||
|
target: SSHParsedTarget,
|
||||||
|
identity: String,
|
||||||
|
options: [String],
|
||||||
|
remoteCommand: [String] = []) -> [String]
|
||||||
|
{
|
||||||
|
var args = options
|
||||||
|
if target.port > 0 {
|
||||||
|
args.append(contentsOf: ["-p", String(target.port)])
|
||||||
|
}
|
||||||
|
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedIdentity.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", trimmedIdentity])
|
||||||
|
}
|
||||||
|
args.append("--")
|
||||||
|
args.append(self.sshTargetString(target))
|
||||||
|
args.append(contentsOf: remoteCommand)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
#if SWIFT_PACKAGE
|
#if SWIFT_PACKAGE
|
||||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||||
self.nodeManagerBinPaths(home: home)
|
self.nodeManagerBinPaths(home: home)
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ struct ConfigSettings: View {
|
|||||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||||
@Bindable var store: ChannelsStore
|
@Bindable var store: ChannelsStore
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
|
@State private var activeSectionKey: String?
|
||||||
|
@State private var activeSubsection: SubsectionSelection?
|
||||||
|
|
||||||
init(store: ChannelsStore = .shared) {
|
init(store: ChannelsStore = .shared) {
|
||||||
self.store = store
|
self.store = store
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
HStack(spacing: 16) {
|
||||||
self.content
|
self.sidebar
|
||||||
|
self.detail
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.task {
|
.task {
|
||||||
guard !self.hasLoaded else { return }
|
guard !self.hasLoaded else { return }
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -22,42 +26,125 @@ struct ConfigSettings: View {
|
|||||||
await self.store.loadConfigSchema()
|
await self.store.loadConfigSchema()
|
||||||
await self.store.loadConfig()
|
await self.store.loadConfig()
|
||||||
}
|
}
|
||||||
|
.onAppear { self.ensureSelection() }
|
||||||
|
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||||
|
if !loading { self.ensureSelection() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConfigSettings {
|
extension ConfigSettings {
|
||||||
private var content: some View {
|
private enum SubsectionSelection: Hashable {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
case all
|
||||||
self.header
|
case key(String)
|
||||||
if let status = self.store.configStatus {
|
}
|
||||||
Text(status)
|
|
||||||
.font(.callout)
|
private struct ConfigSection: Identifiable {
|
||||||
.foregroundStyle(.secondary)
|
let key: String
|
||||||
}
|
let label: String
|
||||||
self.actionRow
|
let help: String?
|
||||||
Group {
|
let node: ConfigSchemaNode
|
||||||
if self.store.configSchemaLoading {
|
|
||||||
ProgressView().controlSize(.small)
|
var id: String { self.key }
|
||||||
} else if let schema = self.store.configSchema {
|
}
|
||||||
ConfigSchemaForm(store: self.store, schema: schema, path: [])
|
|
||||||
.disabled(self.isNixMode)
|
private struct ConfigSubsection: Identifiable {
|
||||||
} else {
|
let key: String
|
||||||
Text("Schema unavailable.")
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
ForEach(self.sections) { section in
|
||||||
|
self.sidebarRow(section)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty, !self.isNixMode {
|
.padding(.vertical, 10)
|
||||||
Text("Unsaved changes")
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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(.horizontal, 24)
|
||||||
.padding(.vertical, 18)
|
.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
|
@ViewBuilder
|
||||||
@@ -71,6 +158,18 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.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 {
|
private var actionRow: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button("Reload") {
|
Button("Reload") {
|
||||||
@@ -85,6 +184,204 @@ extension ConfigSettings {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.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 {
|
struct ConfigSettings_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
|
|||||||
static let shared = ConnectionModeCoordinator()
|
static let shared = ConnectionModeCoordinator()
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||||
|
private var lastMode: AppState.ConnectionMode?
|
||||||
|
|
||||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
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 {
|
switch mode {
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
if let error = await NodeServiceManager.stop() {
|
_ = await NodeServiceManager.stop()
|
||||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
NodesStore.shared.lastError = nil
|
||||||
}
|
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
|
|||||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||||
|
|
||||||
case .local:
|
case .local:
|
||||||
if let error = await NodeServiceManager.stop() {
|
_ = await NodeServiceManager.stop()
|
||||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
NodesStore.shared.lastError = nil
|
||||||
}
|
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
|
|||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
NodesStore.shared.lastError = nil
|
||||||
if let error = await NodeServiceManager.start() {
|
if let error = await NodeServiceManager.start() {
|
||||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
|||||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||||
let showDockIconKey = "clawdbot.showDockIcon"
|
let showDockIconKey = "clawdbot.showDockIcon"
|
||||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
|
let voiceWakeMaxWords = 32
|
||||||
|
let voiceWakeMaxWordLength = 64
|
||||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
|
private(set) var authSourceLabel: String?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||||
|
|
||||||
@@ -87,15 +88,7 @@ final class ControlChannel {
|
|||||||
|
|
||||||
func configure() async {
|
func configure() async {
|
||||||
self.logger.info("control channel configure mode=local")
|
self.logger.info("control channel configure mode=local")
|
||||||
self.state = .connecting
|
await self.refreshEndpoint(reason: "configure")
|
||||||
do {
|
|
||||||
try await GatewayConnection.shared.refresh()
|
|
||||||
self.state = .connected
|
|
||||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
|
||||||
} catch {
|
|
||||||
let message = self.friendlyGatewayMessage(error)
|
|
||||||
self.state = .degraded(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(mode: Mode = .local) async throws {
|
func configure(mode: Mode = .local) async throws {
|
||||||
@@ -111,7 +104,7 @@ final class ControlChannel {
|
|||||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
await self.configure()
|
await self.refreshEndpoint(reason: "configure")
|
||||||
} catch {
|
} catch {
|
||||||
self.state = .degraded(error.localizedDescription)
|
self.state = .degraded(error.localizedDescription)
|
||||||
throw error
|
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 {
|
func disconnect() async {
|
||||||
await GatewayConnection.shared.shutdown()
|
await GatewayConnection.shared.shutdown()
|
||||||
self.state = .disconnected
|
self.state = .disconnected
|
||||||
self.lastPingMs = nil
|
self.lastPingMs = nil
|
||||||
|
self.authSourceLabel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||||
@@ -183,8 +190,11 @@ final class ControlChannel {
|
|||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
{
|
{
|
||||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||||
|
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||||
|
? "gateway.remote.token"
|
||||||
|
: "gateway.auth.token"
|
||||||
return
|
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. " +
|
"or clear it on the gateway. " +
|
||||||
"Reason: \(reason)"
|
"Reason: \(reason)"
|
||||||
}
|
}
|
||||||
@@ -275,18 +285,49 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||||
try await GatewayConnection.shared.refresh()
|
if case .connected = self.state {
|
||||||
self.logger.info("control channel recovery finished")
|
self.logger.info("control channel recovery finished")
|
||||||
} catch {
|
} else if case let .degraded(message) = self.state {
|
||||||
self.logger.error(
|
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.recoveryTask = nil
|
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 {
|
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||||
var merged = params
|
var merged = params
|
||||||
merged["text"] = AnyHashable(text)
|
merged["text"] = AnyHashable(text)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct DebugSettings: View {
|
|||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let healthStore = HealthStore.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 gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
|
||||||
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
|
||||||
@State private var sessionStoreSaveError: String?
|
@State private var sessionStoreSaveError: String?
|
||||||
@@ -47,6 +49,7 @@ struct DebugSettings: View {
|
|||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
self.header
|
self.header
|
||||||
|
|
||||||
|
self.launchdSection
|
||||||
self.appInfoSection
|
self.appInfoSection
|
||||||
self.gatewaySection
|
self.gatewaySection
|
||||||
self.logsSection
|
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 {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
@@ -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) {
|
HStack(spacing: 8) {
|
||||||
Button("Restart app") { DebugActions.restartApp() }
|
Button("Restart app") { DebugActions.restartApp() }
|
||||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||||
|
|||||||
@@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable {
|
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
var pattern: String
|
var pattern: String
|
||||||
var lastUsedAt: Double?
|
var lastUsedAt: Double?
|
||||||
var lastUsedCommand: String?
|
var lastUsedCommand: String?
|
||||||
var lastResolvedPath: String?
|
var lastResolvedPath: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
pattern: String,
|
||||||
|
lastUsedAt: Double? = nil,
|
||||||
|
lastUsedCommand: String? = nil,
|
||||||
|
lastResolvedPath: String? = nil)
|
||||||
|
{
|
||||||
|
self.id = id
|
||||||
|
self.pattern = pattern
|
||||||
|
self.lastUsedAt = lastUsedAt
|
||||||
|
self.lastUsedCommand = lastUsedCommand
|
||||||
|
self.lastResolvedPath = lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case pattern
|
||||||
|
case lastUsedAt
|
||||||
|
case lastUsedCommand
|
||||||
|
case lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
||||||
|
self.pattern = try container.decode(String.self, forKey: .pattern)
|
||||||
|
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
|
||||||
|
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
|
||||||
|
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.id, forKey: .id)
|
||||||
|
try container.encode(self.pattern, forKey: .pattern)
|
||||||
|
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
|
||||||
|
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
|
||||||
|
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalsDefaults: Codable {
|
struct ExecApprovalsDefaults: Codable {
|
||||||
@@ -149,6 +190,7 @@ struct ExecApprovalsResolvedDefaults {
|
|||||||
|
|
||||||
enum ExecApprovalsStore {
|
enum ExecApprovalsStore {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
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 defaultSecurity: ExecSecurity = .deny
|
||||||
private static let defaultAsk: ExecAsk = .onMiss
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
private static let defaultAskFallback: ExecSecurity = .deny
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
@@ -165,13 +207,22 @@ enum ExecApprovalsStore {
|
|||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = file.socket?.token?.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(
|
return ExecApprovalsFile(
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: ExecApprovalsSocketConfig(
|
socket: ExecApprovalsSocketConfig(
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
token: token.isEmpty ? nil : token),
|
token: token.isEmpty ? nil : token),
|
||||||
defaults: file.defaults,
|
defaults: file.defaults,
|
||||||
agents: file.agents)
|
agents: agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
@@ -272,18 +323,20 @@ enum ExecApprovalsStore {
|
|||||||
ask: defaults.ask ?? self.defaultAsk,
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
let key = self.agentKey(agentId)
|
||||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
: "default"
|
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
security: agentEntry.security ?? resolvedDefaults.security,
|
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
?? resolvedDefaults.askFallback,
|
||||||
let allowlist = (agentEntry.allowlist ?? [])
|
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||||
|
?? resolvedDefaults.autoAllowSkills)
|
||||||
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: entry.id,
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: entry.lastUsedAt,
|
lastUsedAt: entry.lastUsedAt,
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
@@ -368,6 +421,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
guard item.pattern == pattern else { return item }
|
guard item.pattern == pattern else { return item }
|
||||||
return ExecAllowlistEntry(
|
return ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern,
|
pattern: item.pattern,
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
@@ -387,6 +441,7 @@ enum ExecApprovalsStore {
|
|||||||
let cleaned = allowlist
|
let cleaned = allowlist
|
||||||
.map { item in
|
.map { item in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: item.lastUsedAt,
|
lastUsedAt: item.lastUsedAt,
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
@@ -454,7 +509,40 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
private static func agentKey(_ agentId: String?) -> String {
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +641,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 {
|
enum ExecAllowlistMatcher {
|
||||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
guard let resolution, !entries.isEmpty else { return nil }
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(payload)
|
let data = try JSONEncoder().encode(payload)
|
||||||
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data)
|
||||||
|
guard self.shouldPresent(request: request) else { return }
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
let decision = ExecApprovalsPromptPresenter.prompt(request.request)
|
||||||
try await GatewayConnection.shared.requestVoid(
|
try await GatewayConnection.shared.requestVoid(
|
||||||
method: .execApprovalResolve,
|
method: .execApprovalResolve,
|
||||||
@@ -56,4 +58,66 @@ final class ExecApprovalsGatewayPrompter {
|
|||||||
self.logger.error("exec approval handling failed \(error.localizedDescription, privacy: .public)")
|
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 ask: String?
|
||||||
var agentId: String?
|
var agentId: String?
|
||||||
var resolvedPath: String?
|
var resolvedPath: String?
|
||||||
|
var sessionKey: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ExecApprovalSocketRequest: Codable {
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
@@ -215,36 +216,15 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
|
alert.informativeText = "Review the command details before allowing."
|
||||||
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
alert.accessoryView = self.buildAccessoryView(request)
|
||||||
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.addButton(withTitle: "Allow Once")
|
alert.addButton(withTitle: "Allow Once")
|
||||||
alert.addButton(withTitle: "Always Allow")
|
alert.addButton(withTitle: "Always Allow")
|
||||||
alert.addButton(withTitle: "Don't 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() {
|
switch alert.runModal() {
|
||||||
case .alertFirstButtonReturn:
|
case .alertFirstButtonReturn:
|
||||||
@@ -255,6 +235,110 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
return .deny
|
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
|
@MainActor
|
||||||
@@ -314,12 +398,12 @@ private enum ExecHostExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
var approvedByAsk = approvalDecision != nil
|
||||||
if self.requiresAsk(
|
if ExecApprovalHelpers.requiresAsk(
|
||||||
ask: context.ask,
|
ask: context.ask,
|
||||||
security: context.security,
|
security: context.security,
|
||||||
allowlistMatch: context.allowlistMatch,
|
allowlistMatch: context.allowlistMatch,
|
||||||
skillAllow: context.skillAllow),
|
skillAllow: context.skillAllow),
|
||||||
approvalDecision == nil
|
approvalDecision == nil
|
||||||
{
|
{
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
ExecApprovalPromptRequest(
|
ExecApprovalPromptRequest(
|
||||||
@@ -329,7 +413,8 @@ private enum ExecHostExecutor {
|
|||||||
security: context.security.rawValue,
|
security: context.security.rawValue,
|
||||||
ask: context.ask.rawValue,
|
ask: context.ask.rawValue,
|
||||||
agentId: context.trimmedAgent,
|
agentId: context.trimmedAgent,
|
||||||
resolvedPath: context.resolution?.resolvedPath))
|
resolvedPath: context.resolution?.resolvedPath,
|
||||||
|
sessionKey: request.sessionKey))
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny:
|
||||||
@@ -417,36 +502,20 @@ private enum ExecHostExecutor {
|
|||||||
skillAllow: skillAllow)
|
skillAllow: skillAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func persistAllowlistEntry(
|
private static func persistAllowlistEntry(
|
||||||
decision: ExecApprovalDecision?,
|
decision: ExecApprovalDecision?,
|
||||||
context: ExecApprovalContext)
|
context: ExecApprovalContext)
|
||||||
{
|
{
|
||||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||||
|
command: context.command,
|
||||||
|
resolution: context.resolution)
|
||||||
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func allowlistPattern(
|
|
||||||
command: [String],
|
|
||||||
resolution: ExecCommandResolution?) -> String?
|
|
||||||
{
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
return pattern.isEmpty ? nil : pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
guard needsScreenRecording == true else { return nil }
|
guard needsScreenRecording == true else { return nil }
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
|
case googlechat
|
||||||
case slack
|
case slack
|
||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
@@ -69,6 +70,7 @@ actor GatewayConnection {
|
|||||||
case channelsLogout = "channels.logout"
|
case channelsLogout = "channels.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
|
case sessionsPreview = "sessions.preview"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
case skillsStatus = "skills.status"
|
case skillsStatus = "skills.status"
|
||||||
@@ -249,6 +251,11 @@ actor GatewayConnection {
|
|||||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
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 {
|
func shutdown() async {
|
||||||
if let client {
|
if let client {
|
||||||
await client.shutdown()
|
await client.shutdown()
|
||||||
@@ -535,6 +542,30 @@ extension GatewayConnection {
|
|||||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
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
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayDiscoveryHelpers {
|
||||||
|
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
|
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||||
|
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||||
|
let user = NSUserName()
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
|
if gateway.sshPort != 22 {
|
||||||
|
target += ":\(gateway.sshPort)"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
|
self.directGatewayUrl(
|
||||||
|
tailnetDns: gateway.tailnetDns,
|
||||||
|
lanHost: gateway.lanHost,
|
||||||
|
gatewayPort: gateway.gatewayPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directGatewayUrl(
|
||||||
|
tailnetDns: String?,
|
||||||
|
lanHost: String?,
|
||||||
|
gatewayPort: Int?) -> String?
|
||||||
|
{
|
||||||
|
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||||
|
return "wss://\(tailnetDns)"
|
||||||
|
}
|
||||||
|
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||||
|
let port = gatewayPort ?? 18789
|
||||||
|
return "ws://\(lanHost):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||||
|
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||||
|
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimmed(_ value: String?) -> String? {
|
||||||
|
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import SwiftUI
|
|||||||
struct GatewayDiscoveryInlineList: View {
|
struct GatewayDiscoveryInlineList: View {
|
||||||
var discovery: GatewayDiscoveryModel
|
var discovery: GatewayDiscoveryModel
|
||||||
var currentTarget: String?
|
var currentTarget: String?
|
||||||
|
var currentUrl: String?
|
||||||
|
var transport: AppState.RemoteTransport
|
||||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||||
|
|
||||||
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
let display = self.displayInfo(for: gateway)
|
||||||
let selected = (target != nil && self.currentTarget?
|
let selected = display.selected
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(target ?? "Gateway pairing only")
|
Text(display.label)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help("Click a discovered gateway to fill the SSH target.")
|
.help(self.transport == .direct
|
||||||
|
? "Click a discovered gateway to fill the gateway URL."
|
||||||
|
: "Click a discovered gateway to fill the SSH target.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
private func displayInfo(
|
||||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||||
guard let host else { return nil }
|
{
|
||||||
let user = NSUserName()
|
switch self.transport {
|
||||||
return GatewayDiscoveryModel.buildSSHTarget(
|
case .direct:
|
||||||
user: user,
|
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||||
host: host,
|
let label = url ?? "Gateway pairing only"
|
||||||
port: gateway.sshPort)
|
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||||
}
|
return (label, selected)
|
||||||
|
case .ssh:
|
||||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||||
guard let host else { return nil }
|
let label = target ?? "Gateway pairing only"
|
||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||||
if trimmed.isEmpty { return nil }
|
return (label, selected)
|
||||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||||
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
if hovered { return Color.secondary.opacity(0.08) }
|
if hovered { return Color.secondary.opacity(0.08) }
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func trimmed(_ value: String?) -> String {
|
||||||
|
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayDiscoveryMenu: View {
|
struct GatewayDiscoveryMenu: View {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
|
|||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
bindMode: bind,
|
bindMode: bind,
|
||||||
customBindHost: customBindHost,
|
customBindHost: customBindHost,
|
||||||
@@ -172,6 +173,10 @@ actor GatewayEndpointStore {
|
|||||||
return configToken
|
return configToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isRemote {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
@@ -306,6 +311,19 @@ actor GatewayEndpointStore {
|
|||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
case .remote:
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.unavailable(
|
||||||
|
mode: .remote,
|
||||||
|
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return
|
||||||
|
}
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||||
@@ -336,6 +354,25 @@ actor GatewayEndpointStore {
|
|||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||||
|
let portInt = UInt16(exactly: port)
|
||||||
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||||
|
}
|
||||||
|
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||||
|
return portInt
|
||||||
|
}
|
||||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
@@ -396,6 +433,21 @@ actor GatewayEndpointStore {
|
|||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||||
guard let ensure = self.remoteEnsure else {
|
guard let ensure = self.remoteEnsure else {
|
||||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
@@ -477,12 +529,13 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
guard bind == "auto" else { return nil }
|
guard bind == "tailnet" else { return nil }
|
||||||
|
|
||||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||||
|
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
?? TailscaleService.fallbackTailnetIPv4()
|
||||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||||
|
|
||||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
@@ -631,11 +684,12 @@ extension GatewayEndpointStore {
|
|||||||
|
|
||||||
static func _testResolveLocalGatewayHost(
|
static func _testResolveLocalGatewayHost(
|
||||||
bindMode: String?,
|
bindMode: String?,
|
||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?,
|
||||||
|
customBindHost: String? = nil) -> String
|
||||||
{
|
{
|
||||||
self.resolveLocalGatewayHost(
|
self.resolveLocalGatewayHost(
|
||||||
bindMode: bindMode,
|
bindMode: bindMode,
|
||||||
customBindHost: nil,
|
customBindHost: customBindHost,
|
||||||
tailscaleIP: tailscaleIP)
|
tailscaleIP: tailscaleIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,46 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
|
private static var disableLaunchAgentMarkerURL: URL {
|
||||||
|
FileManager().homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
|
}
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager().homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.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 {
|
static func isLoaded() async -> Bool {
|
||||||
guard let loaded = await self.readDaemonLoaded() else { return false }
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
||||||
return loaded
|
return loaded
|
||||||
@@ -66,12 +101,6 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
|
||||||
let marker = FileManager().homeDirectoryForCurrentUser
|
|
||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
|
||||||
return FileManager().fileExists(atPath: marker.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
let result = await self.runDaemonCommandResult(
|
let result = await self.runDaemonCommandResult(
|
||||||
["status", "--json", "--no-probe"],
|
["status", "--json", "--no-probe"],
|
||||||
@@ -115,7 +144,7 @@ extension GatewayLaunchAgentManager {
|
|||||||
quiet: Bool) async -> CommandResult
|
quiet: Bool) async -> CommandResult
|
||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "daemon",
|
subcommand: "gateway",
|
||||||
extraArgs: self.withJsonFlag(args),
|
extraArgs: self.withJsonFlag(args),
|
||||||
// Launchd management must always run locally, even if remote mode is configured.
|
// Launchd management must always run locally, even if remote mode is configured.
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
|||||||
@@ -42,10 +42,20 @@ final class GatewayProcessManager {
|
|||||||
private var environmentRefreshTask: Task<Void, Never>?
|
private var environmentRefreshTask: Task<Void, Never>?
|
||||||
private var lastEnvironmentRefresh: Date?
|
private var lastEnvironmentRefresh: Date?
|
||||||
private var logRefreshTask: Task<Void, Never>?
|
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 logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||||
|
|
||||||
private let logLimit = 20000 // characters to keep in-memory
|
private let logLimit = 20000 // characters to keep in-memory
|
||||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||||
|
private var connection: GatewayConnection {
|
||||||
|
#if DEBUG
|
||||||
|
return self.testingConnection ?? .shared
|
||||||
|
#else
|
||||||
|
return .shared
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
func setActive(_ active: Bool) {
|
func setActive(_ active: Bool) {
|
||||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||||
@@ -69,6 +79,11 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
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()
|
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||||
guard !enabled else { return }
|
guard !enabled else { return }
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
@@ -126,6 +141,10 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearLastFailure() {
|
||||||
|
self.lastFailureReason = nil
|
||||||
|
}
|
||||||
|
|
||||||
func refreshEnvironmentStatus(force: Bool = false) {
|
func refreshEnvironmentStatus(force: Bool = false) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if !force {
|
if !force {
|
||||||
@@ -178,7 +197,7 @@ final class GatewayProcessManager {
|
|||||||
let hasListener = instance != nil
|
let hasListener = instance != nil
|
||||||
|
|
||||||
let attemptAttach = {
|
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) {
|
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||||
@@ -187,6 +206,7 @@ final class GatewayProcessManager {
|
|||||||
let snap = decodeHealthSnapshot(from: data)
|
let snap = decodeHealthSnapshot(from: data)
|
||||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||||
self.existingGatewayDetails = details
|
self.existingGatewayDetails = details
|
||||||
|
self.clearLastFailure()
|
||||||
self.status = .attachedExisting(details: details)
|
self.status = .attachedExisting(details: details)
|
||||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||||
self.logger.info("gateway using existing instance details=\(details)")
|
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 {
|
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||||
let instanceText = instance ?? "pid unknown"
|
let instanceText = instance ?? "pid unknown"
|
||||||
if let snap {
|
if let snap {
|
||||||
let linkId = snap.channelOrder?.first(where: {
|
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
let linkId = order.first(where: { snap.channels[$0]?.linked == true })
|
||||||
return false
|
?? order.first(where: { snap.channels[$0]?.linked != nil })
|
||||||
}) ?? snap.channels.keys.first(where: {
|
guard let linkId else {
|
||||||
if let summary = snap.channels[$0] { return summary.linked != nil }
|
return "port \(port), health probe succeeded, \(instanceText)"
|
||||||
return false
|
}
|
||||||
})
|
let linked = snap.channels[linkId]?.linked ?? false
|
||||||
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
|
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||||
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
|
||||||
let label =
|
let label =
|
||||||
linkId.flatMap { snap.channelLabels?[$0] } ??
|
snap.channelLabels?[linkId] ??
|
||||||
linkId?.capitalized ??
|
linkId.capitalized
|
||||||
"channel"
|
|
||||||
let linkText = linked ? "linked" : "not linked"
|
let linkText = linked ? "linked" : "not linked"
|
||||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||||
}
|
}
|
||||||
@@ -293,6 +311,15 @@ final class GatewayProcessManager {
|
|||||||
return
|
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 bundlePath = Bundle.main.bundleURL.path
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
|
||||||
@@ -310,9 +337,10 @@ final class GatewayProcessManager {
|
|||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if !self.desiredActive { return }
|
if !self.desiredActive { return }
|
||||||
do {
|
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 instance = await PortGuardian.shared.describe(port: port)
|
||||||
let details = instance.map { "pid \($0.pid)" }
|
let details = instance.map { "pid \($0.pid)" }
|
||||||
|
self.clearLastFailure()
|
||||||
self.status = .running(details: details)
|
self.status = .running(details: details)
|
||||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||||
@@ -352,7 +380,8 @@ final class GatewayProcessManager {
|
|||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
if !self.desiredActive { return false }
|
if !self.desiredActive { return false }
|
||||||
do {
|
do {
|
||||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||||
|
self.clearLastFailure()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
@@ -385,3 +414,19 @@ final class GatewayProcessManager {
|
|||||||
return String(text.suffix(limit))
|
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
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayRemoteConfig {
|
||||||
|
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let raw = remote["transport"] as? String
|
||||||
|
else {
|
||||||
|
return .ssh
|
||||||
|
}
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let urlRaw = remote["url"] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||||
|
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||||
|
return self.normalizeGatewayUrl(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||||
|
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||||
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||||
|
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !host.isEmpty else { return nil }
|
||||||
|
if scheme == "ws", url.port == nil {
|
||||||
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
components.port = 18789
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultPort(for url: URL) -> Int? {
|
||||||
|
if let port = url.port { return port }
|
||||||
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
switch scheme {
|
||||||
|
case "wss":
|
||||||
|
return 443
|
||||||
|
case "ws":
|
||||||
|
return 18789
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,52 +2,26 @@ import AppKit
|
|||||||
import ClawdbotDiscovery
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
@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 healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||||
localDisplayName: InstanceIdentity.displayName)
|
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 gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||||
@State private var remoteStatus: RemoteStatus = .idle
|
@State private var remoteStatus: RemoteStatus = .idle
|
||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
private var remoteLabelWidth: CGFloat { 88 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
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) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Clawdbot active",
|
title: "Clawdbot active",
|
||||||
@@ -83,29 +57,6 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
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(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
@@ -130,29 +81,13 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshCLIStatus()
|
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
CanvasManager.shared.hideAll()
|
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> {
|
private var activeBinding: Binding<Bool> {
|
||||||
@@ -161,39 +96,20 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
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 {
|
private var connectionSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Clawdbot runs")
|
Text("Clawdbot runs")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Picker("", selection: self.$state.connectionMode) {
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.menu)
|
||||||
.frame(width: 380, alignment: .leading)
|
.labelsHidden()
|
||||||
|
.frame(width: 260, alignment: .leading)
|
||||||
|
|
||||||
if self.state.connectionMode == .unconfigured {
|
if self.state.connectionMode == .unconfigured {
|
||||||
Text("Pick Local or Remote to start the Gateway.")
|
Text("Pick Local or Remote to start the Gateway.")
|
||||||
@@ -216,67 +132,56 @@ struct GeneralSettings: View {
|
|||||||
if self.state.connectionMode == .remote {
|
if self.state.connectionMode == .remote {
|
||||||
self.remoteCard
|
self.remoteCard
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cliInstaller
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var remoteCard: some View {
|
private var remoteCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
self.remoteTransportRow
|
||||||
Text("SSH")
|
|
||||||
.font(.callout.weight(.semibold))
|
if self.state.remoteTransport == .ssh {
|
||||||
.frame(width: 48, alignment: .leading)
|
self.remoteSshRow
|
||||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
} else {
|
||||||
.textFieldStyle(.roundedBorder)
|
self.remoteDirectRow
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
Button {
|
|
||||||
Task { await self.testRemote() }
|
|
||||||
} label: {
|
|
||||||
if self.remoteStatus == .checking {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text("Test remote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
discovery: self.gatewayDiscovery,
|
discovery: self.gatewayDiscovery,
|
||||||
currentTarget: self.state.remoteTarget)
|
currentTarget: self.state.remoteTarget,
|
||||||
|
currentUrl: self.state.remoteUrl,
|
||||||
|
transport: self.state.remoteTransport)
|
||||||
{ gateway in
|
{ gateway in
|
||||||
self.applyDiscoveredGateway(gateway)
|
self.applyDiscoveredGateway(gateway)
|
||||||
}
|
}
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
self.remoteStatusView
|
self.remoteStatusView
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
if self.state.remoteTransport == .ssh {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||||
LabeledContent("Identity file") {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
LabeledContent("Identity file") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
.frame(width: 280)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: 280)
|
||||||
LabeledContent("Project root") {
|
}
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
LabeledContent("Project root") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
.frame(width: 280)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: 280)
|
||||||
LabeledContent("CLI path") {
|
}
|
||||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
LabeledContent("CLI path") {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||||
.frame(width: 280)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 280)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
} label: {
|
||||||
|
Text("Advanced")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
|
||||||
} label: {
|
|
||||||
Text("Advanced")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostics
|
// Diagnostics
|
||||||
@@ -299,18 +204,107 @@ struct GeneralSettings: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||||
|
Text(authLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
if self.state.remoteTransport == .ssh {
|
||||||
.font(.footnote)
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
.foregroundStyle(.secondary)
|
.font(.footnote)
|
||||||
.lineLimit(1)
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.onAppear { self.gatewayDiscovery.start() }
|
.onAppear { self.gatewayDiscovery.start() }
|
||||||
.onDisappear { self.gatewayDiscovery.stop() }
|
.onDisappear { self.gatewayDiscovery.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var remoteTransportRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("Transport")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||||
|
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||||
|
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteSshRow: some View {
|
||||||
|
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||||
|
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("SSH target")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.remoteStatus == .checking || !canTest)
|
||||||
|
}
|
||||||
|
if let validationMessage {
|
||||||
|
Text(validationMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteDirectRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("Gateway")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: "Connected"
|
case .connected: "Connected"
|
||||||
@@ -346,59 +340,6 @@ struct GeneralSettings: View {
|
|||||||
return message == self.controlStatusLine
|
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 {
|
private var gatewayInstallerCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@@ -454,22 +395,6 @@ struct GeneralSettings: View {
|
|||||||
.cornerRadius(10)
|
.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() {
|
private func refreshGatewayStatus() {
|
||||||
Task {
|
Task {
|
||||||
let status = await Task.detached(priority: .utility) {
|
let status = await Task.detached(priority: .utility) {
|
||||||
@@ -609,24 +534,43 @@ extension GeneralSettings {
|
|||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
guard !settings.target.isEmpty else {
|
if self.state.remoteTransport == .direct {
|
||||||
self.remoteStatus = .failed("Set an SSH target first")
|
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return
|
guard !trimmedUrl.isEmpty else {
|
||||||
|
self.remoteStatus = .failed("Set a gateway URL first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||||
|
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
guard !settings.target.isEmpty else {
|
||||||
|
self.remoteStatus = .failed("Set an SSH target first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: basic SSH reachability check
|
||||||
|
guard let sshCommand = Self.sshCheckCommand(
|
||||||
|
target: settings.target,
|
||||||
|
identity: settings.identity)
|
||||||
|
else {
|
||||||
|
self.remoteStatus = .failed("SSH target is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sshResult = await ShellExecutor.run(
|
||||||
|
command: sshCommand,
|
||||||
|
cwd: nil,
|
||||||
|
env: nil,
|
||||||
|
timeout: 8)
|
||||||
|
|
||||||
|
guard sshResult.ok else {
|
||||||
|
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: basic SSH reachability check
|
// Step 2: control channel health check
|
||||||
let sshResult = await ShellExecutor.run(
|
|
||||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
|
||||||
cwd: nil,
|
|
||||||
env: nil,
|
|
||||||
timeout: 8)
|
|
||||||
|
|
||||||
guard sshResult.ok else {
|
|
||||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: control channel health over tunnel
|
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
@@ -653,20 +597,28 @@ extension GeneralSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||||
var args: [String] = [
|
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||||
"/usr/bin/ssh",
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||||
|
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return !host.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
||||||
|
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
||||||
|
let options = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "ConnectTimeout=5",
|
"-o", "ConnectTimeout=5",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "UpdateHostKeys=yes",
|
"-o", "UpdateHostKeys=yes",
|
||||||
]
|
]
|
||||||
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
let args = CommandResolver.sshArguments(
|
||||||
args.append(contentsOf: ["-i", identity])
|
target: parsed,
|
||||||
}
|
identity: identity,
|
||||||
args.append(target)
|
options: options,
|
||||||
args.append("echo ok")
|
remoteCommand: ["echo", "ok"])
|
||||||
return args
|
return ["/usr/bin/ssh"] + args
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
||||||
@@ -721,12 +673,18 @@ extension GeneralSettings {
|
|||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
guard let host else { return }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
if self.state.remoteTransport == .direct {
|
||||||
user: user,
|
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||||
host: host,
|
self.state.remoteUrl = url
|
||||||
port: gateway.sshPort)
|
}
|
||||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
} else {
|
||||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
|
user: user,
|
||||||
|
host: host,
|
||||||
|
port: gateway.sshPort)
|
||||||
|
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||||
|
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +707,9 @@ extension GeneralSettings {
|
|||||||
static func exerciseForTesting() {
|
static func exerciseForTesting() {
|
||||||
let state = AppState(preview: true)
|
let state = AppState(preview: true)
|
||||||
state.connectionMode = .remote
|
state.connectionMode = .remote
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.remoteTarget = "user@host:2222"
|
state.remoteTarget = "user@host:2222"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "/tmp/id_ed25519"
|
state.remoteIdentity = "/tmp/id_ed25519"
|
||||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||||
state.remoteCliPath = "/tmp/clawdbot"
|
state.remoteCliPath = "/tmp/clawdbot"
|
||||||
@@ -763,9 +723,6 @@ extension GeneralSettings {
|
|||||||
message: "Gateway ready")
|
message: "Gateway ready")
|
||||||
view.remoteStatus = .failed("SSH failed")
|
view.remoteStatus = .failed("SSH failed")
|
||||||
view.showRemoteAdvanced = true
|
view.showRemoteAdvanced = true
|
||||||
view.cliInstalled = true
|
|
||||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
|
||||||
view.cliStatus = "Installed"
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
|
|
||||||
state.connectionMode = .unconfigured
|
state.connectionMode = .unconfigured
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ final class HealthStore {
|
|||||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
|
||||||
{
|
{
|
||||||
let order = snap.channelOrder ?? Array(snap.channels.keys)
|
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 {
|
for id in order {
|
||||||
if let summary = snap.channels[id], summary.linked != nil {
|
if let summary = snap.channels[id], summary.linked != nil {
|
||||||
return (id: id, summary: summary)
|
return (id: id, summary: summary)
|
||||||
@@ -235,8 +240,8 @@ final class HealthStore {
|
|||||||
let lower = error.lowercased()
|
let lower = error.lowercased()
|
||||||
if lower.contains("connection refused") {
|
if lower.contains("connection refused") {
|
||||||
let port = GatewayEnvironment.gatewayPort()
|
let port = GatewayEnvironment.gatewayPort()
|
||||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
"restart Clawdbot to bring it back."
|
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||||
}
|
}
|
||||||
if lower.contains("timeout") {
|
if lower.contains("timeout") {
|
||||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Darwin
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MenuBarExtraAccess
|
import MenuBarExtraAccess
|
||||||
import Observation
|
import Observation
|
||||||
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -10,9 +11,11 @@ import SwiftUI
|
|||||||
struct ClawdbotApp: App {
|
struct ClawdbotApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@State private var state: AppState
|
@State private var state: AppState
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
private let controlChannel = ControlChannel.shared
|
private let controlChannel = ControlChannel.shared
|
||||||
private let activityStore = WorkActivityStore.shared
|
private let activityStore = WorkActivityStore.shared
|
||||||
|
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
@@ -30,6 +33,7 @@ struct ClawdbotApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
ClawdbotLogging.bootstrapIfNeeded()
|
ClawdbotLogging.bootstrapIfNeeded()
|
||||||
|
Self.applyAttachOnlyOverrideIfNeeded()
|
||||||
_state = State(initialValue: AppStateStore.shared)
|
_state = State(initialValue: AppStateStore.shared)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,22 @@ struct ClawdbotApp: App {
|
|||||||
self.statusItem?.button?.appearsDisabled = paused || sleeping
|
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 {
|
private var isGatewaySleeping: Bool {
|
||||||
if self.state.isPaused { return false }
|
if self.state.isPaused { return false }
|
||||||
switch self.state.connectionMode {
|
switch self.state.connectionMode {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -18,6 +20,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
|
private var isObservingControlChannel = false
|
||||||
|
|
||||||
private var cachedSnapshot: SessionStoreSnapshot?
|
private var cachedSnapshot: SessionStoreSnapshot?
|
||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
@@ -50,6 +53,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.loadTask = Task { await self.refreshCache(force: true) }
|
self.loadTask = Task { await self.refreshCache(force: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.startControlChannelObservation()
|
||||||
self.nodesStore.start()
|
self.nodesStore.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +100,50 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.cancelPreviewTasks()
|
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) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
self.originalDelegate?.menuNeedsUpdate?(menu)
|
self.originalDelegate?.menuNeedsUpdate?(menu)
|
||||||
}
|
}
|
||||||
@@ -141,14 +189,23 @@ extension MenuSessionsInjector {
|
|||||||
if rhs.key == mainKey { return false }
|
if rhs.key == mainKey { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
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()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
|
let statusText = self
|
||||||
|
.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: rows.count,
|
count: rows.count,
|
||||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
statusText: statusText)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -461,15 +518,29 @@ extension MenuSessionsInjector {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
platform = "remote"
|
platform = "remote"
|
||||||
let target = AppStateStore.shared.remoteTarget
|
if AppStateStore.shared.remoteTransport == .direct {
|
||||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
||||||
|
if let port = url.port {
|
||||||
|
host = "\(urlHost):\(port)"
|
||||||
|
} else {
|
||||||
|
host = urlHost
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
host = trimmedUrl.nonEmpty
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
host = target.nonEmpty
|
let target = AppStateStore.shared.remoteTarget
|
||||||
|
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||||
|
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||||
|
} else {
|
||||||
|
host = target.nonEmpty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
host = "127.0.0.1:\(port)"
|
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
platform = nil
|
platform = nil
|
||||||
host = nil
|
host = nil
|
||||||
@@ -598,8 +669,11 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedSnapshot = nil
|
if self.cachedSnapshot != nil {
|
||||||
self.cachedErrorText = nil
|
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||||
|
} else {
|
||||||
|
self.cachedErrorText = nil
|
||||||
|
}
|
||||||
self.cacheUpdatedAt = Date()
|
self.cacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -624,8 +698,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedUsageSummary = nil
|
|
||||||
self.cachedUsageErrorText = nil
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
self.usageCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -648,8 +720,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
guard self.isControlChannelConnected else {
|
||||||
self.cachedCostSummary = nil
|
|
||||||
self.cachedCostErrorText = nil
|
|
||||||
self.costCacheUpdatedAt = Date()
|
self.costCacheUpdatedAt = Date()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,28 @@ import Foundation
|
|||||||
import JavaScriptCore
|
import JavaScriptCore
|
||||||
|
|
||||||
enum ModelCatalogLoader {
|
enum ModelCatalogLoader {
|
||||||
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
|
static var defaultPath: String { self.resolveDefaultPath() }
|
||||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
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] {
|
static func load(from path: String) async throws -> [ModelChoice] {
|
||||||
let expanded = (path as NSString).expandingTildeInPath
|
let expanded = (path as NSString).expandingTildeInPath
|
||||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
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 sanitized = self.sanitize(source: source)
|
||||||
|
|
||||||
let ctx = JSContext()
|
let ctx = JSContext()
|
||||||
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
|
|||||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||||
}
|
}
|
||||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||||
|
if resolved.shouldCache {
|
||||||
|
self.cacheCatalog(sourcePath: resolved.path)
|
||||||
|
}
|
||||||
return sorted
|
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 {
|
private static func sanitize(source: String) -> String {
|
||||||
guard let exportRange = source.range(of: "export const MODELS"),
|
guard let exportRange = source.range(of: "export const MODELS"),
|
||||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||||
|
|||||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
}
|
}
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
let approval = await self.resolveSystemRunApproval(
|
||||||
if ask == .always { return true }
|
req: req,
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
params: params,
|
||||||
return false
|
context: ExecRunContext(
|
||||||
}()
|
displayCommand: displayCommand,
|
||||||
|
security: security,
|
||||||
let approvedByAsk = params.approved == true
|
ask: ask,
|
||||||
if requiresAsk, !approvedByAsk {
|
agentId: agentId,
|
||||||
await self.emitExecEvent(
|
resolution: resolution,
|
||||||
"exec.denied",
|
allowlistMatch: allowlistMatch,
|
||||||
payload: ExecEventPayload(
|
skillAllow: skillAllow,
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId))
|
||||||
host: "node",
|
if let response = approval.response { return response }
|
||||||
command: displayCommand,
|
let approvedByAsk = approval.approvedByAsk
|
||||||
reason: "approval-required"))
|
let persistAllowlist = approval.persistAllowlist
|
||||||
return Self.errorResponse(
|
if persistAllowlist, security == .allowlist,
|
||||||
req,
|
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||||
code: .unavailable,
|
{
|
||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||||
@@ -619,6 +619,100 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
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 {
|
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
|||||||
@@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
|
|||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
|
||||||
var args = [
|
let options = [
|
||||||
"-o",
|
"-o", "BatchMode=yes",
|
||||||
"BatchMode=yes",
|
"-o", "ConnectTimeout=5",
|
||||||
"-o",
|
"-o", "NumberOfPasswordPrompts=0",
|
||||||
"ConnectTimeout=5",
|
"-o", "PreferredAuthentications=publickey",
|
||||||
"-o",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"NumberOfPasswordPrompts=0",
|
|
||||||
"-o",
|
|
||||||
"PreferredAuthentications=publickey",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
]
|
]
|
||||||
if port > 0, port != 22 {
|
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||||
args.append(contentsOf: ["-p", String(port)])
|
return false
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
|
let args = CommandResolver.sshArguments(
|
||||||
|
target: target,
|
||||||
|
identity: "",
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["/usr/bin/true"])
|
||||||
process.arguments = args
|
process.arguments = args
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ extension OnboardingView {
|
|||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
if self.state.remoteTransport == .direct {
|
||||||
|
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||||
|
self.state.remoteUrl = url
|
||||||
|
}
|
||||||
|
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
@@ -177,42 +177,77 @@ extension OnboardingView {
|
|||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("SSH target")
|
Text("Transport")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||||
.textFieldStyle(.roundedBorder)
|
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||||
.frame(width: fieldWidth)
|
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
GridRow {
|
if self.state.remoteTransport == .direct {
|
||||||
Text("Identity file")
|
GridRow {
|
||||||
.font(.callout.weight(.semibold))
|
Text("Gateway URL")
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.font(.callout.weight(.semibold))
|
||||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
GridRow {
|
if self.state.remoteTransport == .ssh {
|
||||||
Text("Project root")
|
GridRow {
|
||||||
.font(.callout.weight(.semibold))
|
Text("SSH target")
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
.font(.callout.weight(.semibold))
|
||||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||||
.frame(width: fieldWidth)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
.frame(width: fieldWidth)
|
||||||
GridRow {
|
}
|
||||||
Text("CLI path")
|
if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
|
||||||
.font(.callout.weight(.semibold))
|
GridRow {
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
Text("")
|
||||||
TextField(
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
"/Applications/Clawdbot.app/.../clawdbot",
|
Text(message)
|
||||||
text: self.$state.remoteCliPath)
|
.font(.caption)
|
||||||
.textFieldStyle(.roundedBorder)
|
.foregroundStyle(.red)
|
||||||
.frame(width: fieldWidth)
|
.frame(width: fieldWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Text("Identity file")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Text("Project root")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Text("CLI path")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
TextField(
|
||||||
|
"/Applications/Clawdbot.app/.../clawdbot",
|
||||||
|
text: self.$state.remoteCliPath)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
Text(self.state.remoteTransport == .direct
|
||||||
|
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||||
|
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -225,7 +260,10 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
if self.state.remoteTransport == .direct {
|
||||||
|
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||||
|
}
|
||||||
|
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
return "\(host)\(portSuffix)"
|
return "\(host)\(portSuffix)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PermissionsSettings: View {
|
struct PermissionsSettings: View {
|
||||||
@@ -8,6 +10,8 @@ struct PermissionsSettings: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
SystemRunSettingsView()
|
||||||
|
|
||||||
Text("Allow these so Clawdbot can notify and capture when needed.")
|
Text("Allow these so Clawdbot can notify and capture when needed.")
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
|
||||||
@@ -15,6 +19,8 @@ struct PermissionsSettings: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
LocationAccessSettings()
|
||||||
|
|
||||||
Button("Restart onboarding") { self.showOnboarding() }
|
Button("Restart onboarding") { self.showOnboarding() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Spacer()
|
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 {
|
struct PermissionStatusList: View {
|
||||||
let status: [Capability: Bool]
|
let status: [Capability: Bool]
|
||||||
let refresh: () async -> Void
|
let refresh: () async -> Void
|
||||||
@@ -45,25 +117,6 @@ struct PermissionStatusList: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
.help("Refresh status")
|
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] {
|
private func listeners(on port: Int) async -> [Listener] {
|
||||||
let res = await ShellExecutor.run(
|
let res = await ShellExecutor.run(
|
||||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ final class RemotePortTunnel {
|
|||||||
"ssh tunnel using default remote port " +
|
"ssh tunnel using default remote port " +
|
||||||
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
||||||
}
|
}
|
||||||
var args: [String] = [
|
let options: [String] = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "IdentitiesOnly=yes",
|
|
||||||
"-o", "ExitOnForwardFailure=yes",
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "UpdateHostKeys=yes",
|
"-o", "UpdateHostKeys=yes",
|
||||||
@@ -82,11 +81,11 @@ final class RemotePortTunnel {
|
|||||||
"-N",
|
"-N",
|
||||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||||
]
|
]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
let args = CommandResolver.sshArguments(
|
||||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
target: parsed,
|
||||||
args.append(userHost)
|
identity: identity,
|
||||||
|
options: options)
|
||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ actor RemoteTunnelManager {
|
|||||||
tunnel.process.isRunning,
|
tunnel.process.isRunning,
|
||||||
let local = tunnel.localPort
|
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)")
|
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||||
return local
|
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()
|
await self.beginRestart()
|
||||||
tunnel.terminate()
|
tunnel.terminate()
|
||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
@@ -35,19 +37,11 @@ actor RemoteTunnelManager {
|
|||||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||||
self.isSshProcess(desc)
|
self.isSshProcess(desc)
|
||||||
{
|
{
|
||||||
if await self.isTunnelHealthy(port: desiredPort) {
|
self.logger.info(
|
||||||
self.logger.info(
|
"reusing existing SSH tunnel listener " +
|
||||||
"reusing existing SSH tunnel listener " +
|
"localPort=\(desiredPort, privacy: .public) " +
|
||||||
"localPort=\(desiredPort, privacy: .public) " +
|
"pid=\(desc.pid, privacy: .public)")
|
||||||
"pid=\(desc.pid, privacy: .public)")
|
return desiredPort
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -88,10 +82,6 @@ actor RemoteTunnelManager {
|
|||||||
self.controlTunnel = nil
|
self.controlTunnel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
|
||||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||||
let cmd = desc.command.lowercased()
|
let cmd = desc.command.lowercased()
|
||||||
if cmd.contains("ssh") { return true }
|
if cmd.contains("ssh") { return true }
|
||||||
@@ -128,21 +118,5 @@ actor RemoteTunnelManager {
|
|||||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
@@ -173,4 +173,4 @@
|
|||||||
"iPod5,1": "iPod touch (5th generation)",
|
"iPod5,1": "iPod touch (5th generation)",
|
||||||
"iPod7,1": "iPod touch (6th generation)",
|
"iPod7,1": "iPod touch (6th generation)",
|
||||||
"iPod9,1": "iPod touch (7th generation)"
|
"iPod9,1": "iPod touch (7th generation)"
|
||||||
}
|
}
|
||||||
@@ -211,4 +211,4 @@
|
|||||||
"Mac Pro (2019)",
|
"Mac Pro (2019)",
|
||||||
"Mac Pro (Rack, 2019)"
|
"Mac Pro (Rack, 2019)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.20</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601200</string>
|
<string>202601250</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -31,31 +32,80 @@ actor SessionPreviewCache {
|
|||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
let items: [SessionPreviewItem]
|
let snapshot: SessionMenuPreviewSnapshot
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [String: CacheEntry] = [:]
|
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 let entry = self.entries[sessionKey] else { return nil }
|
||||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge 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) {
|
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||||
self.entries[sessionKey]?.items
|
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
|
#if DEBUG
|
||||||
extension SessionPreviewCache {
|
extension SessionPreviewCache {
|
||||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
func _testSet(
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
snapshot: SessionMenuPreviewSnapshot,
|
||||||
|
for sessionKey: String,
|
||||||
|
updatedAt: Date = Date())
|
||||||
|
{
|
||||||
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _testReset() {
|
func _testReset() {
|
||||||
@@ -174,36 +224,44 @@ enum SessionMenuPreviewLoader {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
private static let previewMaxChars = 240
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
var errorDescription: String? { "preview timeout" }
|
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 {
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||||
return self.snapshot(from: cached)
|
for: sessionKey,
|
||||||
|
maxAge: cacheMaxAgeSeconds)
|
||||||
|
{
|
||||||
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||||
seconds: self.previewTimeoutSeconds,
|
return snapshot
|
||||||
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)
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||||
if let fallback {
|
return fallback
|
||||||
return Self.snapshot(from: fallback)
|
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
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 {
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
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 {
|
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(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
{
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||||
let messages = self.decodeMessages(raw)
|
let messages = self.decodeMessages(raw)
|
||||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
@@ -235,7 +395,7 @@ enum SessionMenuPreviewLoader {
|
|||||||
return SessionPreviewItem(id: id, role: role, text: text)
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = built.suffix(maxItems)
|
let trimmed = built.suffix(boundedItems)
|
||||||
return Array(trimmed.reversed())
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,12 +408,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
|
|
||||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
if isTool { return .tool }
|
if isTool { return .tool }
|
||||||
|
return self.previewRoleFromRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "user": return .user
|
case "user": .user
|
||||||
case "assistant": return .assistant
|
case "assistant": .assistant
|
||||||
case "system": return .system
|
case "system": .system
|
||||||
case "tool": return .tool
|
case "tool": .tool
|
||||||
default: return .other
|
default: .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,4 +480,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
return result
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
ForEach(self.model.entries, id: \.id) { entry in
|
||||||
ExecAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entries[index] },
|
get: { self.model.entry(for: entry.id) ?? entry },
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
set: { self.model.updateEntry($0, id: entry.id) }),
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
onRemove: { self.model.removeEntry(id: entry.id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
|
|||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func entry(for id: UUID) -> ExecAllowlistEntry? {
|
||||||
|
self.entries.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
func refreshSkillBins(force: Bool = false) async {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
guard self.autoAllowSkills else {
|
guard self.autoAllowSkills else {
|
||||||
self.skillBins = []
|
self.skillBins = []
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import AppKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import os
|
import os
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Manages Tailscale integration and status checking.
|
/// Manages Tailscale integration and status checking.
|
||||||
@Observable
|
@Observable
|
||||||
@@ -100,16 +103,14 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkTailscaleStatus() async {
|
func checkTailscaleStatus() async {
|
||||||
|
let previousIP = self.tailscaleIP
|
||||||
self.isInstalled = self.checkAppInstallation()
|
self.isInstalled = self.checkAppInstallation()
|
||||||
guard self.isInstalled else {
|
if !self.isInstalled {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
self.tailscaleHostname = nil
|
self.tailscaleHostname = nil
|
||||||
self.tailscaleIP = nil
|
self.tailscaleIP = nil
|
||||||
self.statusError = "Tailscale is not installed"
|
self.statusError = "Tailscale is not installed"
|
||||||
return
|
} else if let apiResponse = await fetchTailscaleStatus() {
|
||||||
}
|
|
||||||
|
|
||||||
if let apiResponse = await fetchTailscaleStatus() {
|
|
||||||
self.isRunning = apiResponse.status.lowercased() == "running"
|
self.isRunning = apiResponse.status.lowercased() == "running"
|
||||||
|
|
||||||
if self.isRunning {
|
if self.isRunning {
|
||||||
@@ -138,6 +139,19 @@ final class TailscaleService {
|
|||||||
self.statusError = "Please start the Tailscale app"
|
self.statusError = "Please start the Tailscale app"
|
||||||
self.logger.info("Tailscale API not responding; app likely not running")
|
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() {
|
func openTailscaleApp() {
|
||||||
@@ -163,4 +177,50 @@ final class TailscaleService {
|
|||||||
NSWorkspace.shared.open(url)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(voiceWakeMaxWords)
|
||||||
|
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
||||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var micObserver = AudioInputDeviceObserver()
|
@State private var micObserver = AudioInputDeviceObserver()
|
||||||
@State private var micRefreshTask: Task<Void, Never>?
|
@State private var micRefreshTask: Task<Void, Never>?
|
||||||
@State private var availableLocales: [Locale] = []
|
@State private var availableLocales: [Locale] = []
|
||||||
|
@State private var triggerEntries: [TriggerEntry] = []
|
||||||
private let fieldLabelWidth: CGFloat = 140
|
private let fieldLabelWidth: CGFloat = 140
|
||||||
private let controlWidth: CGFloat = 240
|
private let controlWidth: CGFloat = 240
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
|
|||||||
var id: String { self.uid }
|
var id: String { self.uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct IndexedWord: Identifiable {
|
private struct TriggerEntry: Identifiable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let value: String
|
var value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
} else {
|
} else {
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var indexedWords: [IndexedWord] {
|
private func loadTriggerEntries() {
|
||||||
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
|
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncTriggerEntriesToState() {
|
||||||
|
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var triggerTable: some View {
|
private var triggerTable: some View {
|
||||||
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Add word", systemImage: "plus")
|
Label("Add word", systemImage: "plus")
|
||||||
}
|
}
|
||||||
.disabled(self.state.swabbleTriggerWords
|
.disabled(self.triggerEntries
|
||||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
Button("Reset defaults") {
|
||||||
|
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table(self.indexedWords) {
|
VStack(spacing: 0) {
|
||||||
TableColumn("Word") { row in
|
ForEach(self.$triggerEntries) { $entry in
|
||||||
TextField("Wake word", text: self.binding(for: row.id))
|
HStack(spacing: 8) {
|
||||||
.textFieldStyle(.roundedBorder)
|
TextField("Wake word", text: $entry.value)
|
||||||
}
|
.textFieldStyle(.roundedBorder)
|
||||||
TableColumn("") { row in
|
.onSubmit {
|
||||||
Button {
|
self.syncTriggerEntriesToState()
|
||||||
self.removeWord(at: row.id)
|
}
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
Button {
|
||||||
|
self.removeWord(id: entry.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove trigger word")
|
||||||
|
.frame(width: 24)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
if entry.id != self.triggerEntries.last?.id {
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.help("Remove trigger word")
|
|
||||||
}
|
}
|
||||||
.width(36)
|
|
||||||
}
|
}
|
||||||
.frame(minHeight: 180)
|
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
|
||||||
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addWord() {
|
private func addWord() {
|
||||||
self.state.swabbleTriggerWords.append("")
|
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeWord(at index: Int) {
|
private func removeWord(id: UUID) {
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
self.triggerEntries.removeAll { $0.id == id }
|
||||||
self.state.swabbleTriggerWords.remove(at: index)
|
self.syncTriggerEntriesToState()
|
||||||
}
|
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
|
|
||||||
return self.state.swabbleTriggerWords[index]
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
|
||||||
self.state.swabbleTriggerWords[index] = newValue
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleTest() {
|
private func toggleTest() {
|
||||||
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
|
|||||||
state.voicePushToTalkEnabled = true
|
state.voicePushToTalkEnabled = true
|
||||||
state.swabbleTriggerWords = ["Claude", "Hey"]
|
state.swabbleTriggerWords = ["Claude", "Hey"]
|
||||||
|
|
||||||
let view = VoiceWakeSettings(state: state, isActive: true)
|
var view = VoiceWakeSettings(state: state, isActive: true)
|
||||||
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
||||||
view.availableLocales = [Locale(identifier: "en_US")]
|
view.availableLocales = [Locale(identifier: "en_US")]
|
||||||
view.meterLevel = 0.42
|
view.meterLevel = 0.42
|
||||||
view.meterError = "No input"
|
view.meterError = "No input"
|
||||||
view.testState = .detected("ok")
|
view.testState = .detected("ok")
|
||||||
view.isTesting = true
|
view.isTesting = true
|
||||||
|
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
||||||
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.localePicker
|
_ = view.localePicker
|
||||||
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
|
|||||||
_ = view.chimeSection
|
_ = view.chimeSection
|
||||||
|
|
||||||
view.addWord()
|
view.addWord()
|
||||||
_ = view.binding(for: 0).wrappedValue
|
if let entryId = view.triggerEntries.first?.id {
|
||||||
view.removeWord(at: 0)
|
view.removeWord(id: entryId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import ClawdbotProtocol
|
import ClawdbotProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(Darwin)
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ConnectOptions {
|
struct ConnectOptions {
|
||||||
var url: String?
|
var url: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
var password: String?
|
var password: String?
|
||||||
var mode: String?
|
var mode: String?
|
||||||
var timeoutMs: Int = 15_000
|
var timeoutMs: Int = 15000
|
||||||
var json: Bool = false
|
var json: Bool = false
|
||||||
var probe: Bool = false
|
var probe: Bool = false
|
||||||
var clientId: String = "clawdbot-macos"
|
var clientId: String = "clawdbot-macos"
|
||||||
@@ -19,53 +22,43 @@ struct ConnectOptions {
|
|||||||
|
|
||||||
static func parse(_ args: [String]) -> ConnectOptions {
|
static func parse(_ args: [String]) -> ConnectOptions {
|
||||||
var opts = 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
|
var i = 0
|
||||||
while i < args.count {
|
while i < args.count {
|
||||||
let arg = args[i]
|
let arg = args[i]
|
||||||
switch arg {
|
if let handler = flagHandlers[arg] {
|
||||||
case "-h", "--help":
|
handler(&opts)
|
||||||
opts.help = true
|
i += 1
|
||||||
case "--json":
|
continue
|
||||||
opts.json = true
|
}
|
||||||
case "--probe":
|
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
|
||||||
opts.probe = true
|
handler(&opts, value)
|
||||||
case "--url":
|
i += 1
|
||||||
opts.url = self.nextValue(args, index: &i)
|
continue
|
||||||
case "--token":
|
|
||||||
opts.token = self.nextValue(args, index: &i)
|
|
||||||
case "--password":
|
|
||||||
opts.password = self.nextValue(args, index: &i)
|
|
||||||
case "--mode":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.mode = value
|
|
||||||
}
|
|
||||||
case "--timeout":
|
|
||||||
if let raw = self.nextValue(args, index: &i),
|
|
||||||
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
||||||
{
|
|
||||||
opts.timeoutMs = max(250, parsed)
|
|
||||||
}
|
|
||||||
case "--client-id":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.clientId = value
|
|
||||||
}
|
|
||||||
case "--client-mode":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.clientMode = value
|
|
||||||
}
|
|
||||||
case "--display-name":
|
|
||||||
opts.displayName = self.nextValue(args, index: &i)
|
|
||||||
case "--role":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.role = value
|
|
||||||
}
|
|
||||||
case "--scopes":
|
|
||||||
if let value = self.nextValue(args, index: &i) {
|
|
||||||
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
@@ -254,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
|
|
||||||
if resolvedMode == "remote" {
|
if resolvedMode == "remote" {
|
||||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!raw.isEmpty else {
|
!raw.isEmpty
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
|
||||||
}
|
}
|
||||||
guard let url = URL(string: raw) else {
|
guard let url = URL(string: raw) else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
|
||||||
@@ -268,9 +265,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = config.port ?? 18789
|
let port = config.port ?? 18789
|
||||||
let host = "127.0.0.1"
|
let host = resolveLocalHost(bind: config.bind)
|
||||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||||
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
|
||||||
}
|
}
|
||||||
return GatewayEndpoint(
|
return GatewayEndpoint(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -280,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
|
||||||
return try? resolveGatewayEndpoint(opts: opts, config: config)
|
try? resolveGatewayEndpoint(opts: opts, config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
|
||||||
@@ -304,3 +304,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa
|
|||||||
}
|
}
|
||||||
return config.password
|
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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
public let commands: [String]?
|
public let commands: [String]?
|
||||||
public let permissions: [String: AnyCodable]?
|
public let permissions: [String: AnyCodable]?
|
||||||
|
public let pathenv: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
public let scopes: [String]?
|
public let scopes: [String]?
|
||||||
public let device: [String: AnyCodable]?
|
public let device: [String: AnyCodable]?
|
||||||
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
commands: [String]?,
|
commands: [String]?,
|
||||||
permissions: [String: AnyCodable]?,
|
permissions: [String: AnyCodable]?,
|
||||||
|
pathenv: String?,
|
||||||
role: String?,
|
role: String?,
|
||||||
scopes: [String]?,
|
scopes: [String]?,
|
||||||
device: [String: AnyCodable]?,
|
device: [String: AnyCodable]?,
|
||||||
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.caps = caps
|
self.caps = caps
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
self.pathenv = pathenv
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case caps
|
case caps
|
||||||
case commands
|
case commands
|
||||||
case permissions
|
case permissions
|
||||||
|
case pathenv = "pathEnv"
|
||||||
case role
|
case role
|
||||||
case scopes
|
case scopes
|
||||||
case device
|
case device
|
||||||
@@ -381,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
|
public let mediaurls: [String]?
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@@ -391,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
|
mediaurls: [String]?,
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@@ -400,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
|
self.mediaurls = mediaurls
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@@ -410,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
|
case mediaurls = "mediaUrls"
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@@ -473,6 +481,10 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let replychannel: String?
|
public let replychannel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
|
public let threadid: String?
|
||||||
|
public let groupid: String?
|
||||||
|
public let groupchannel: String?
|
||||||
|
public let groupspace: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -494,6 +506,10 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
replychannel: String?,
|
replychannel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
|
threadid: String?,
|
||||||
|
groupid: String?,
|
||||||
|
groupchannel: String?,
|
||||||
|
groupspace: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -514,6 +530,10 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.replychannel = replychannel
|
self.replychannel = replychannel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
|
self.threadid = threadid
|
||||||
|
self.groupid = groupid
|
||||||
|
self.groupchannel = groupchannel
|
||||||
|
self.groupspace = groupspace
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -535,6 +555,10 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case replychannel = "replyChannel"
|
case replychannel = "replyChannel"
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
|
case threadid = "threadId"
|
||||||
|
case groupid = "groupId"
|
||||||
|
case groupchannel = "groupChannel"
|
||||||
|
case groupspace = "groupSpace"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -544,6 +568,44 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityParams: Codable, Sendable {
|
||||||
|
public let agentid: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String?,
|
||||||
|
sessionkey: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AgentIdentityResult: Codable, Sendable {
|
||||||
|
public let agentid: String
|
||||||
|
public let name: String?
|
||||||
|
public let avatar: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
agentid: String,
|
||||||
|
name: String?,
|
||||||
|
avatar: String?
|
||||||
|
) {
|
||||||
|
self.agentid = agentid
|
||||||
|
self.name = name
|
||||||
|
self.avatar = avatar
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case agentid = "agentId"
|
||||||
|
case name
|
||||||
|
case avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct AgentWaitParams: Codable, Sendable {
|
public struct AgentWaitParams: Codable, Sendable {
|
||||||
public let runid: String
|
public let runid: String
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
@@ -835,40 +897,74 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
public let includeglobal: Bool?
|
||||||
public let includeunknown: Bool?
|
public let includeunknown: Bool?
|
||||||
|
public let includederivedtitles: Bool?
|
||||||
|
public let includelastmessage: Bool?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
|
public let search: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
limit: Int?,
|
limit: Int?,
|
||||||
activeminutes: Int?,
|
activeminutes: Int?,
|
||||||
includeglobal: Bool?,
|
includeglobal: Bool?,
|
||||||
includeunknown: Bool?,
|
includeunknown: Bool?,
|
||||||
|
includederivedtitles: Bool?,
|
||||||
|
includelastmessage: Bool?,
|
||||||
label: String?,
|
label: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
agentid: String?
|
agentid: String?,
|
||||||
|
search: String?
|
||||||
) {
|
) {
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.activeminutes = activeminutes
|
self.activeminutes = activeminutes
|
||||||
self.includeglobal = includeglobal
|
self.includeglobal = includeglobal
|
||||||
self.includeunknown = includeunknown
|
self.includeunknown = includeunknown
|
||||||
|
self.includederivedtitles = includederivedtitles
|
||||||
|
self.includelastmessage = includelastmessage
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
self.search = search
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case limit
|
case limit
|
||||||
case activeminutes = "activeMinutes"
|
case activeminutes = "activeMinutes"
|
||||||
case includeglobal = "includeGlobal"
|
case includeglobal = "includeGlobal"
|
||||||
case includeunknown = "includeUnknown"
|
case includeunknown = "includeUnknown"
|
||||||
|
case includederivedtitles = "includeDerivedTitles"
|
||||||
|
case includelastmessage = "includeLastMessage"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
|
case search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SessionsPreviewParams: Codable, Sendable {
|
||||||
|
public let keys: [String]
|
||||||
|
public let limit: Int?
|
||||||
|
public let maxchars: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int?,
|
||||||
|
maxchars: Int?
|
||||||
|
) {
|
||||||
|
self.keys = keys
|
||||||
|
self.limit = limit
|
||||||
|
self.maxchars = maxchars
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case keys
|
||||||
|
case limit
|
||||||
|
case maxchars = "maxChars"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
|
public let sessionid: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
@@ -877,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String?,
|
key: String?,
|
||||||
|
sessionid: String?,
|
||||||
label: String?,
|
label: String?,
|
||||||
agentid: String?,
|
agentid: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
@@ -884,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
includeunknown: Bool?
|
includeunknown: Bool?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.sessionid = sessionid
|
||||||
self.label = label
|
self.label = label
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
@@ -892,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
|
case sessionid = "sessionId"
|
||||||
case label
|
case label
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
@@ -1068,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
|
|||||||
public struct ConfigPatchParams: Codable, Sendable {
|
public struct ConfigPatchParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
public let basehash: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
public let note: String?
|
||||||
|
public let restartdelayms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?
|
basehash: String?,
|
||||||
|
sessionkey: String?,
|
||||||
|
note: String?,
|
||||||
|
restartdelayms: Int?
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
self.basehash = basehash
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
self.note = note
|
||||||
|
self.restartdelayms = restartdelayms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
case basehash = "baseHash"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
case note
|
||||||
|
case restartdelayms = "restartDelayMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,17 +1538,21 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
|||||||
public struct AgentSummary: Codable, Sendable {
|
public struct AgentSummary: Codable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let name: String?
|
public let name: String?
|
||||||
|
public let identity: [String: AnyCodable]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
name: String?
|
name: String?,
|
||||||
|
identity: [String: AnyCodable]?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.identity = identity
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
|
case identity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,27 +2003,30 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: String?
|
public let cwd: AnyCodable?
|
||||||
public let host: String?
|
public let host: AnyCodable?
|
||||||
public let security: String?
|
public let security: AnyCodable?
|
||||||
public let ask: String?
|
public let ask: AnyCodable?
|
||||||
public let agentid: String?
|
public let agentid: AnyCodable?
|
||||||
public let resolvedpath: String?
|
public let resolvedpath: AnyCodable?
|
||||||
public let sessionkey: String?
|
public let sessionkey: AnyCodable?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: String?,
|
cwd: AnyCodable?,
|
||||||
host: String?,
|
host: AnyCodable?,
|
||||||
security: String?,
|
security: AnyCodable?,
|
||||||
ask: String?,
|
ask: AnyCodable?,
|
||||||
agentid: String?,
|
agentid: AnyCodable?,
|
||||||
resolvedpath: String?,
|
resolvedpath: AnyCodable?,
|
||||||
sessionkey: String?,
|
sessionkey: AnyCodable?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
|
self.id = id
|
||||||
self.command = command
|
self.command = command
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -1920,6 +2038,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
|||||||
self.timeoutms = timeoutms
|
self.timeoutms = timeoutms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
case command
|
case command
|
||||||
case cwd
|
case cwd
|
||||||
case host
|
case host
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import SwiftUI
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
|
||||||
|
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ChannelsSettingsSmokeTests {
|
struct ChannelsSettingsSmokeTests {
|
||||||
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"linked": true,
|
"linked": true,
|
||||||
"authAgeMs": 86_400_000,
|
"authAgeMs": 86_400_000,
|
||||||
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"lastEventAt": 1_700_000_060_000,
|
"lastEventAt": 1_700_000_060_000,
|
||||||
"lastError": "needs login",
|
"lastError": "needs login",
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"tokenSource": "env",
|
"tokenSource": "env",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": true,
|
"configured": true,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": true,
|
"running": true,
|
||||||
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_050_000,
|
"lastProbeAt": 1_700_000_050_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
"signal": "Signal",
|
"signal": "Signal",
|
||||||
"imessage": "iMessage",
|
"imessage": "iMessage",
|
||||||
],
|
],
|
||||||
|
channelDetailLabels: nil,
|
||||||
|
channelSystemImages: nil,
|
||||||
|
channelMeta: nil,
|
||||||
channels: [
|
channels: [
|
||||||
"whatsapp": AnyCodable([
|
"whatsapp": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"linked": false,
|
"linked": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"connected": false,
|
"connected": false,
|
||||||
"reconnectAttempts": 0,
|
"reconnectAttempts": 0,
|
||||||
]),
|
]),
|
||||||
"telegram": AnyCodable([
|
"telegram": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "bot missing",
|
"lastError": "bot missing",
|
||||||
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_100_000,
|
"lastProbeAt": 1_700_000_100_000,
|
||||||
]),
|
]),
|
||||||
"signal": AnyCodable([
|
"signal": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"baseUrl": "http://127.0.0.1:8080",
|
"baseUrl": "http://127.0.0.1:8080",
|
||||||
"running": false,
|
"running": false,
|
||||||
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
|
|||||||
],
|
],
|
||||||
"lastProbeAt": 1_700_000_200_000,
|
"lastProbeAt": 1_700_000_200_000,
|
||||||
]),
|
]),
|
||||||
"imessage": AnyCodable([
|
"imessage": SnapshotAnyCodable([
|
||||||
"configured": false,
|
"configured": false,
|
||||||
"running": false,
|
"running": false,
|
||||||
"lastError": "not configured",
|
"lastError": "not configured",
|
||||||
|
|||||||
@@ -123,11 +123,16 @@ import Testing
|
|||||||
configRoot: [:])
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
if let marker = cmd.firstIndex(of: "--") {
|
||||||
|
#expect(cmd[marker + 1] == "clawd@example.com")
|
||||||
|
} else {
|
||||||
|
#expect(Bool(false))
|
||||||
|
}
|
||||||
#expect(cmd.contains("-i"))
|
#expect(cmd.contains("-i"))
|
||||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||||
if let script = cmd.last {
|
if let script = cmd.last {
|
||||||
#expect(script.contains("cd '/srv/clawdbot'"))
|
#expect(script.contains("PRJ='/srv/clawdbot'"))
|
||||||
|
#expect(script.contains("cd \"$PRJ\""))
|
||||||
#expect(script.contains("clawdbot"))
|
#expect(script.contains("clawdbot"))
|
||||||
#expect(script.contains("status"))
|
#expect(script.contains("status"))
|
||||||
#expect(script.contains("--json"))
|
#expect(script.contains("--json"))
|
||||||
@@ -135,6 +140,12 @@ import Testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func rejectsUnsafeSSHTargets() async throws {
|
||||||
|
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
|
||||||
|
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
|
||||||
|
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||||
let defaults = self.makeDefaults()
|
let defaults = self.makeDefaults()
|
||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
|
|||||||
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForNewJob() {
|
@Test func cronJobEditorBuildsBodyForNewJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let job = CronJob(
|
let job = CronJob(
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
agentId: "ops",
|
agentId: "ops",
|
||||||
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
|
|||||||
job: job,
|
job: job,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorExercisesBuilders() {
|
@Test func cronJobEditorExercisesBuilders() {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
var view = CronJobEditor(
|
var view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
view.exerciseForTesting()
|
view.exerciseForTesting()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||||
|
let channelsStore = ChannelsStore(isPreview: true)
|
||||||
let view = CronJobEditor(
|
let view = CronJobEditor(
|
||||||
job: nil,
|
job: nil,
|
||||||
isSaving: .constant(false),
|
isSaving: .constant(false),
|
||||||
error: .constant(nil),
|
error: .constant(nil),
|
||||||
|
channelsStore: channelsStore,
|
||||||
onCancel: {},
|
onCancel: {},
|
||||||
onSave: { _ in })
|
onSave: { _ in })
|
||||||
|
|
||||||
var root: [String: Any] = [:]
|
var root: [String: Any] = [:]
|
||||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
|
||||||
let raw = root["deleteAfterRun"] as? Bool
|
let raw = root["deleteAfterRun"] as? Bool
|
||||||
#expect(raw == true)
|
#expect(raw == true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite struct ExecApprovalHelpersTests {
|
||||||
|
@Test func parseDecisionTrimsAndRejectsInvalid() {
|
||||||
|
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
|
||||||
|
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
|
||||||
|
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
|
||||||
|
#expect(ExecApprovalHelpers.parseDecision("") == nil)
|
||||||
|
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func allowlistPatternPrefersResolution() {
|
||||||
|
let resolved = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
|
||||||
|
|
||||||
|
let rawOnly = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: nil,
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
|
||||||
|
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
|
||||||
|
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func requiresAskMatchesPolicy() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
|
||||||
|
#expect(ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: .always,
|
||||||
|
security: .deny,
|
||||||
|
allowlistMatch: nil,
|
||||||
|
skillAllow: false))
|
||||||
|
#expect(ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: .onMiss,
|
||||||
|
security: .allowlist,
|
||||||
|
allowlistMatch: nil,
|
||||||
|
skillAllow: false))
|
||||||
|
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: .onMiss,
|
||||||
|
security: .allowlist,
|
||||||
|
allowlistMatch: entry,
|
||||||
|
skillAllow: false))
|
||||||
|
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: .onMiss,
|
||||||
|
security: .allowlist,
|
||||||
|
allowlistMatch: nil,
|
||||||
|
skillAllow: true))
|
||||||
|
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||||
|
ask: .off,
|
||||||
|
security: .allowlist,
|
||||||
|
allowlistMatch: nil,
|
||||||
|
skillAllow: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite
|
||||||
|
@MainActor
|
||||||
|
struct ExecApprovalsGatewayPrompterTests {
|
||||||
|
@Test func sessionMatchPrefersActiveSession() {
|
||||||
|
let matches = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: " main ",
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: nil)
|
||||||
|
#expect(matches)
|
||||||
|
|
||||||
|
let mismatched = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: "other",
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 0)
|
||||||
|
#expect(!mismatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sessionFallbackUsesRecentActivity() {
|
||||||
|
let recent = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 10,
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
#expect(recent)
|
||||||
|
|
||||||
|
let stale = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: "main",
|
||||||
|
lastInputSeconds: 200,
|
||||||
|
thresholdSeconds: 120)
|
||||||
|
#expect(!stale)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultBehaviorMatchesMode() {
|
||||||
|
let local = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .local,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: nil,
|
||||||
|
lastInputSeconds: 400)
|
||||||
|
#expect(local)
|
||||||
|
|
||||||
|
let remote = ExecApprovalsGatewayPrompter._testShouldPresent(
|
||||||
|
mode: .remote,
|
||||||
|
activeSession: nil,
|
||||||
|
requestSession: nil,
|
||||||
|
lastInputSeconds: 400)
|
||||||
|
#expect(!remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
|
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
@@ -19,6 +20,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||||
|
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
||||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import Testing
|
import Testing
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|||||||
@@ -139,4 +139,46 @@ import Testing
|
|||||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||||
#expect(resolved.mode == .remote)
|
#expect(resolved.mode == .remote)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostUsesLoopbackForAutoEvenWithTailnet() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: "100.64.1.2")
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostUsesLoopbackForAutoWithoutTailnet() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "auto",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: "100.64.1.5")
|
||||||
|
#expect(host == "100.64.1.5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: nil)
|
||||||
|
#expect(host == "127.0.0.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
|
||||||
|
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "custom",
|
||||||
|
tailscaleIP: "100.64.1.9",
|
||||||
|
customBindHost: "192.168.1.10")
|
||||||
|
#expect(host == "192.168.1.10")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||||
|
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||||
|
#expect(url?.port == 18789)
|
||||||
|
#expect(url?.absoluteString == "ws://gateway:18789")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 1939 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user