Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e341d1354 | ||
|
|
323200b551 | ||
|
|
dbe156e881 | ||
|
|
f00038b383 | ||
|
|
3b6739d3e9 | ||
|
|
d7055f8fd2 | ||
|
|
1fc213468b | ||
|
|
af1749f3b3 | ||
|
|
2b15e952c2 | ||
|
|
343b6ac31b | ||
|
|
9046296ed3 | ||
|
|
b4e9a0c975 | ||
|
|
71791d5a6a | ||
|
|
7acdaad04e | ||
|
|
b7ac9095e6 | ||
|
|
f42fca667c | ||
|
|
be3648c511 | ||
|
|
5fa682d8f0 | ||
|
|
30348e41c6 | ||
|
|
7343597075 | ||
|
|
0b2ff4cfd9 | ||
|
|
50e62122bb | ||
|
|
eeae5ce7fd | ||
|
|
edb3651c32 | ||
|
|
3155e4fd5a | ||
|
|
5a443dfa53 | ||
|
|
cfdca57551 | ||
|
|
473f7df658 | ||
|
|
57e6a9a762 | ||
|
|
7660a78330 | ||
|
|
29884f8d6f | ||
|
|
76c5bff7d6 | ||
|
|
f74ead8d43 | ||
|
|
bfd0dcde35 | ||
|
|
38604acd94 | ||
|
|
c928df7237 | ||
|
|
30b4c14296 | ||
|
|
2daead27cf | ||
|
|
d38b232724 | ||
|
|
c3587d6cae | ||
|
|
b3b507c6ea | ||
|
|
7879a58f4b | ||
|
|
579b00503f | ||
|
|
36a21ae9b0 | ||
|
|
11f897b7df | ||
|
|
054a6d301c | ||
|
|
1f9b4e3af6 | ||
|
|
4ce2e73521 | ||
|
|
c7caa9a87d | ||
|
|
7a518166bb | ||
|
|
89291c384b | ||
|
|
9d802abd9a | ||
|
|
480bf916e2 | ||
|
|
9a4021a277 | ||
|
|
2b07a2a8ab | ||
|
|
77bc11f91c | ||
|
|
7890bd7369 | ||
|
|
b6982236a6 | ||
|
|
6c54977c15 | ||
|
|
494f41d575 | ||
|
|
cffec07329 | ||
|
|
6833e3de5d | ||
|
|
20b4e2b859 | ||
|
|
ff14e743ea | ||
|
|
6668805aca | ||
|
|
3a113b7752 | ||
|
|
e229a36e9f | ||
|
|
f5670cae06 | ||
|
|
cc79c507f6 | ||
|
|
b0b3896941 | ||
|
|
9b6bc0e66b | ||
|
|
f8d168bde0 | ||
|
|
325ed80252 | ||
|
|
e43abd3f14 | ||
|
|
d9645b4802 | ||
|
|
5ec3663748 | ||
|
|
84d9c5f5e5 | ||
|
|
f3bd6e4957 | ||
|
|
6cb55eaaa7 | ||
|
|
3f27b23d5a | ||
|
|
4102e2f1b8 | ||
|
|
35d42be828 | ||
|
|
6a2b8328df | ||
|
|
cc8e6e00a0 | ||
|
|
6e0c1cb051 | ||
|
|
8f9aa3e8c5 | ||
|
|
88c404bcfc | ||
|
|
920436da65 | ||
|
|
4759633df1 | ||
|
|
e824b3514b | ||
|
|
2e2f05a0e1 | ||
|
|
02270abc87 | ||
|
|
2cc0d8c058 | ||
|
|
340d1c64b4 | ||
|
|
2d74119a08 | ||
|
|
e0bf86f06c | ||
|
|
df55d45b6f | ||
|
|
305ef06090 | ||
|
|
a665382060 | ||
|
|
49f99e200a | ||
|
|
fa0f2b971f | ||
|
|
fe46a2663b | ||
|
|
a32021dc3e | ||
|
|
4cf3e84b39 | ||
|
|
24c3ab6fe0 | ||
|
|
d8f1124d59 | ||
|
|
07be761779 | ||
|
|
b0b4b33b6b | ||
|
|
d33285a9cd | ||
|
|
49e7004664 | ||
|
|
0637e4b2a5 | ||
|
|
3e6d27ac4e | ||
|
|
506cc9e7a1 | ||
|
|
21ba04755b | ||
|
|
cbac9fe4ac | ||
|
|
b339097179 | ||
|
|
07eed3de56 | ||
|
|
326fb04d12 | ||
|
|
d2098e4492 | ||
|
|
362fc3e235 | ||
|
|
6444258ad3 | ||
|
|
3dbd6766ab | ||
|
|
318f59ec3e | ||
|
|
57dafec0ec | ||
|
|
518dfd4e42 | ||
|
|
9984248f51 | ||
|
|
9cb1bfa1c1 | ||
|
|
5fa3ac1e01 | ||
|
|
f3882671c9 | ||
|
|
7c76561569 | ||
|
|
bd2002010c | ||
|
|
317e15c746 | ||
|
|
40f818ff5e | ||
|
|
1d9199b529 | ||
|
|
cb213b55f6 | ||
|
|
7a52a93d08 | ||
|
|
d4a93bc25c | ||
|
|
3853f632e5 | ||
|
|
7fb0b4e1eb | ||
|
|
04951b0629 | ||
|
|
eff092268a | ||
|
|
b977e8a284 | ||
|
|
621f710d60 | ||
|
|
c731a87d07 | ||
|
|
786eac1d6f | ||
|
|
1eb50ffac4 | ||
|
|
67b7877bbf | ||
|
|
3166cc911b | ||
|
|
5adbeb1bad | ||
|
|
4d0e74ab6c | ||
|
|
a580639abf | ||
|
|
494743a4e5 | ||
|
|
4eb6aec016 | ||
|
|
5a47d6ffc3 | ||
|
|
0afa370869 | ||
|
|
08cc8f2281 | ||
|
|
708f04b02f | ||
|
|
1c257f170a | ||
|
|
06052640e8 | ||
|
|
fa61699f9a | ||
|
|
98377c7c6b | ||
|
|
805a29252e | ||
|
|
f699dc3777 | ||
|
|
ad17966e2f | ||
|
|
2a86e40730 | ||
|
|
b5cd758c21 | ||
|
|
56b11ad5a8 | ||
|
|
1110d96769 | ||
|
|
050c1c5391 | ||
|
|
357891a063 | ||
|
|
66bc003126 | ||
|
|
55d2608808 | ||
|
|
a6a9930a34 | ||
|
|
6d70524aa8 | ||
|
|
ee5acd6d4b | ||
|
|
aa30995aa1 | ||
|
|
d45c27e51f | ||
|
|
67fdee6d6b | ||
|
|
0d00d6dfd4 | ||
|
|
6546a1a23a | ||
|
|
72d4317d7f | ||
|
|
6d01d70c24 | ||
|
|
dafa8a2881 | ||
|
|
ab314a22e0 | ||
|
|
c65114be1a | ||
|
|
19d9e7ac05 | ||
|
|
d19972b317 | ||
|
|
9790b39d80 | ||
|
|
b9b1bc2726 | ||
|
|
8a194b4abc | ||
|
|
46e00ad5e7 | ||
|
|
3389231ecb | ||
|
|
d772ff06c8 | ||
|
|
d9290137bc | ||
|
|
686b3f884c | ||
|
|
914216eca4 | ||
|
|
0ef429f532 | ||
|
|
9bd5d4355c | ||
|
|
1bd5500832 | ||
|
|
cf192f8551 | ||
|
|
afede929b3 | ||
|
|
d44bb41d27 | ||
|
|
fa346d7b78 | ||
|
|
ec1047583a | ||
|
|
7e6fa94720 | ||
|
|
4933113252 | ||
|
|
2f050b197e | ||
|
|
4c4c167416 | ||
|
|
777fb6b7bb | ||
|
|
9f9098406c | ||
|
|
4533dd6e5d | ||
|
|
212b13b099 | ||
|
|
c409edd3fa | ||
|
|
193ebba657 | ||
|
|
fac4951f27 | ||
|
|
c4e76eb635 | ||
|
|
0279f09459 | ||
|
|
831de9ba8f | ||
|
|
7947059884 | ||
|
|
1fe9f648b1 | ||
|
|
0335bccd91 | ||
|
|
f9347235e4 | ||
|
|
b1664ec9c7 | ||
|
|
801e7dd811 | ||
|
|
82f71d25e5 | ||
|
|
b977ae19af | ||
|
|
c0a010335b | ||
|
|
d41372b9d9 | ||
|
|
8c1d39064d | ||
|
|
7c925aa5a0 | ||
|
|
651a9e9be4 | ||
|
|
c264e98c62 | ||
|
|
1ac7338d9a | ||
|
|
8dbb22cc93 | ||
|
|
59e6064006 | ||
|
|
f648267dd9 | ||
|
|
26ce65995f | ||
|
|
0de3bb36d5 | ||
|
|
755c031f6a | ||
|
|
7ac628a697 | ||
|
|
7dd0899856 | ||
|
|
8dd8818e08 | ||
|
|
f1a1032cd6 | ||
|
|
b383fbeed3 | ||
|
|
91c870a0c4 | ||
|
|
5a57cbe571 | ||
|
|
6480ef369f | ||
|
|
2455a2b26a | ||
|
|
2d105d16f8 | ||
|
|
7a836c9ff0 | ||
|
|
738269eb74 | ||
|
|
9b5ce2530a | ||
|
|
38d6930fbe | ||
|
|
0d98e93253 | ||
|
|
64babcac7a | ||
|
|
464f0645a8 | ||
|
|
ef08c3f038 | ||
|
|
48ad3bbbe6 | ||
|
|
843ff5f2d4 | ||
|
|
60bf349201 | ||
|
|
a54706a063 | ||
|
|
6cc8570369 | ||
|
|
dd958fddfc | ||
|
|
12722acb55 | ||
|
|
687a10b8cc | ||
|
|
9f80d8ec7c | ||
|
|
dcc41e932d | ||
|
|
e3cd431551 | ||
|
|
e2ea02160d | ||
|
|
89b20baafe | ||
|
|
e2733d21bf | ||
|
|
701e146c06 | ||
|
|
8bc9209094 | ||
|
|
a1533a17f7 | ||
|
|
84d64f9395 | ||
|
|
fb03149df4 | ||
|
|
ab994d2c63 | ||
|
|
a1ded60bca | ||
|
|
f62f4b6703 | ||
|
|
de5b75eff6 | ||
|
|
bf0184d0cf | ||
|
|
64525f825c | ||
|
|
5805bb051b | ||
|
|
ef3bab5a74 | ||
|
|
f428ed9038 | ||
|
|
e4fea2b80b | ||
|
|
d1943a9337 | ||
|
|
d781508952 | ||
|
|
22144cd51b | ||
|
|
b7fdc266ad | ||
|
|
b99eb4c9f3 | ||
|
|
239e9bafc8 | ||
|
|
8978ac425e | ||
|
|
81f9093c3c | ||
|
|
41c8bdfada | ||
|
|
8b47368167 | ||
|
|
e60c3fc1b3 | ||
|
|
db5e4b986b | ||
|
|
78532d76bd | ||
|
|
04f2972b4a | ||
|
|
e87ce9c680 | ||
|
|
53a0c966a5 | ||
|
|
d6d5c5ccd1 | ||
|
|
001a19eb2c | ||
|
|
43b530ca1c | ||
|
|
44564df028 | ||
|
|
70c1732dd1 | ||
|
|
99e9e506be | ||
|
|
5a93447294 | ||
|
|
a39951d463 | ||
|
|
1281c1d155 | ||
|
|
236f8560b3 | ||
|
|
ae3711bfbd | ||
|
|
449bee9645 | ||
|
|
4d146ea2f5 | ||
|
|
3b5149ca39 | ||
|
|
4c86da044e | ||
|
|
d01e06f09a | ||
|
|
c782404bee | ||
|
|
6019c1e718 | ||
|
|
65c2532cd5 | ||
|
|
3e2e3eb023 | ||
|
|
0258c746bc | ||
|
|
920b3880c1 | ||
|
|
66db6c749d | ||
|
|
e4abd06094 | ||
|
|
98abf3983a | ||
|
|
10102e1cf2 | ||
|
|
a057f6a306 | ||
|
|
2045395ccb | ||
|
|
d3674f4d6c | ||
|
|
cdb915d527 | ||
|
|
a7c8341452 | ||
|
|
7b478909b2 | ||
|
|
7b392ca74b | ||
|
|
eee04fa2ce | ||
|
|
025f794f0f | ||
|
|
4fac94f259 | ||
|
|
0f409cb99d | ||
|
|
241bc737cf | ||
|
|
cc8d3d331a | ||
|
|
5ec2018c8a | ||
|
|
6b9e1b9dbb | ||
|
|
16b4df4a9d | ||
|
|
2a0d8f6e38 | ||
|
|
056c4ae622 | ||
|
|
6274adce3a | ||
|
|
2772b39e4a | ||
|
|
4f79122068 | ||
|
|
7376d1e6c9 | ||
|
|
f918d30a58 | ||
|
|
782863ea6c | ||
|
|
cc1c5f800f | ||
|
|
f241859c98 | ||
|
|
8466e53b5d | ||
|
|
8e63cd9a76 | ||
|
|
895cd06ecc | ||
|
|
2cfed7952e | ||
|
|
8a5fb796c0 | ||
|
|
34664601e0 | ||
|
|
d693f02fa7 | ||
|
|
003cda73e8 | ||
|
|
afe6f182ca | ||
|
|
5a6ae2624e | ||
|
|
2dc7872ad1 | ||
|
|
8b579c91a5 | ||
|
|
63b0a16357 | ||
|
|
f28a4a34ad | ||
|
|
4075895c4c | ||
|
|
9cd2662a86 | ||
|
|
8f8caa8d89 | ||
|
|
991f6dda38 | ||
|
|
2485701835 | ||
|
|
f634db5c17 | ||
|
|
f0700e9778 | ||
|
|
172fc777ed | ||
|
|
a6822e1210 | ||
|
|
5b8f2911df | ||
|
|
ede3cd78c8 | ||
|
|
092d357187 | ||
|
|
38e2362be6 | ||
|
|
b5858c0148 | ||
|
|
a29f5dda2e | ||
|
|
3ed877a813 | ||
|
|
28b7e87c99 | ||
|
|
623d1e11f1 | ||
|
|
f4b3869f45 | ||
|
|
c56b2f4bc1 | ||
|
|
c75d77e36c | ||
|
|
45ff927980 | ||
|
|
a4ea47be37 | ||
|
|
cb10682d3e | ||
|
|
5fedfd8d15 | ||
|
|
8c7d1781bc | ||
|
|
626b085c85 | ||
|
|
9a7f050568 | ||
|
|
ce786762db | ||
|
|
2f036f7173 | ||
|
|
05a99aa49b | ||
|
|
60bd65dfac | ||
|
|
43975a39dc | ||
|
|
c91ec2aab7 | ||
|
|
5898304fa0 | ||
|
|
a0a64a625e | ||
|
|
53ec8e36cb | ||
|
|
82ffcfb181 | ||
|
|
20d4773f14 | ||
|
|
52565864d1 | ||
|
|
1fd7a6e310 | ||
|
|
08015fbce6 | ||
|
|
a9bd01b523 | ||
|
|
7b8ac0d5ad | ||
|
|
251ed83680 | ||
|
|
d75b302699 | ||
|
|
b4fbf2fe0d | ||
|
|
5843733978 | ||
|
|
c69c4caa33 | ||
|
|
05bd100f7a | ||
|
|
a399fa36c8 | ||
|
|
9b1f164447 | ||
|
|
3554004968 | ||
|
|
74485f171b | ||
|
|
0cb28e26fc | ||
|
|
103dd3af64 | ||
|
|
21eebb6d3b | ||
|
|
d1e10af1e1 | ||
|
|
18338bc60f | ||
|
|
77c7387bbd | ||
|
|
fd7450e5b9 | ||
|
|
e311dc82e0 | ||
|
|
097550c299 | ||
|
|
9a8d3aed26 | ||
|
|
e18080163f | ||
|
|
eea686c81e | ||
|
|
bd10f3d3f1 | ||
|
|
a25922a21f | ||
|
|
96e17d407a | ||
|
|
a9a70ea278 | ||
|
|
695be8e92d | ||
|
|
c17d3a0738 | ||
|
|
b6b1b570f9 | ||
|
|
6016a63162 | ||
|
|
6f6acd94cc | ||
|
|
2396a66e82 | ||
|
|
1277f6e27b | ||
|
|
c9c5a71d8b | ||
|
|
30b3d6ce61 | ||
|
|
875c62689e | ||
|
|
51ec578cec | ||
|
|
2d0ca67c21 | ||
|
|
88cbe2d275 | ||
|
|
98d0318d4e | ||
|
|
e3c340fd38 | ||
|
|
89dc6ebb8b | ||
|
|
90d6a55e05 | ||
|
|
a18743eabc | ||
|
|
a9f8719cd2 | ||
|
|
0730e15c6c | ||
|
|
f4b9a00d37 | ||
|
|
5bc3d15bba | ||
|
|
9c05675d66 | ||
|
|
94e7a98bf2 | ||
|
|
65cc92c06a | ||
|
|
870c9f0b99 | ||
|
|
3b91148a0a | ||
|
|
22b3bd4415 | ||
|
|
bdee50da6b | ||
|
|
50461c23c0 | ||
|
|
8a9c38e66e | ||
|
|
8624feb36a | ||
|
|
15757d01bc | ||
|
|
2af3853bfa | ||
|
|
3f1415b8fe | ||
|
|
79f5ccc99d | ||
|
|
3adec35632 | ||
|
|
dc3a0bfd1e | ||
|
|
79af03ba5e | ||
|
|
f8bf041396 | ||
|
|
92cc7a841c | ||
|
|
40b4341a1d | ||
|
|
35083fcb37 | ||
|
|
53f51786f2 | ||
|
|
6c7a27c010 | ||
|
|
83270f98f7 | ||
|
|
304ec1abe5 | ||
|
|
55e830b009 | ||
|
|
402c35b91c | ||
|
|
6220106ab2 | ||
|
|
c37b77855b | ||
|
|
84046cbad8 | ||
|
|
c27b1441f7 | ||
|
|
8890fbcf38 | ||
|
|
8ae0429162 | ||
|
|
909c14d443 | ||
|
|
b4663ed11c | ||
|
|
29e6f13b29 | ||
|
|
1be8d06cca | ||
|
|
42a0089b3b | ||
|
|
cb2a365594 | ||
|
|
1985790f7f | ||
|
|
6f5503688d | ||
|
|
a5065b354e | ||
|
|
5fa26bfec7 | ||
|
|
1a97aadb6b | ||
|
|
7641b142ad | ||
|
|
374aa856f2 | ||
|
|
cfeaa34c16 | ||
|
|
1689ef0b97 | ||
|
|
2bb9716598 | ||
|
|
564caf49bb | ||
|
|
24605379b9 | ||
|
|
cf8d7139e1 | ||
|
|
c6fc7c2ea6 | ||
|
|
e8dbb350ae | ||
|
|
4861f09f78 | ||
|
|
0297b38ce0 | ||
|
|
65cb9dc3f7 | ||
|
|
e0089bb4eb | ||
|
|
76964162c7 | ||
|
|
2396df0d97 | ||
|
|
d1b0a3584f | ||
|
|
90be8c632a | ||
|
|
4121f9e6dc | ||
|
|
d309d4fe8b | ||
|
|
e73b812236 | ||
|
|
09769d127f | ||
|
|
2469384643 | ||
|
|
81e7e25b3a | ||
|
|
f97fbbf2b8 | ||
|
|
6d378ee608 | ||
|
|
46f0a08878 | ||
|
|
fb989cd0f8 | ||
|
|
d3a0114b6b | ||
|
|
e4cd03033a | ||
|
|
a65455e25d | ||
|
|
9185fdc896 | ||
|
|
a05c4fca5c | ||
|
|
85fab7afe3 | ||
|
|
2977b296e6 | ||
|
|
0a4cb0d264 | ||
|
|
9ecef09c69 | ||
|
|
6481806751 | ||
|
|
60bd21d77b | ||
|
|
7e00fc4ebc | ||
|
|
1acbd6aea0 | ||
|
|
71a08eed84 | ||
|
|
72b0777341 | ||
|
|
0edacd0469 | ||
|
|
237480ed9b | ||
|
|
837cec64af | ||
|
|
423eef4624 | ||
|
|
133e30c594 | ||
|
|
7e81980747 | ||
|
|
f0a909f6dd | ||
|
|
840ccbccf6 | ||
|
|
ade06cb9fb | ||
|
|
d23bba9d24 | ||
|
|
5755d85ad1 | ||
|
|
50a5b4ddcc | ||
|
|
04512ee67c | ||
|
|
47a8b410ee | ||
|
|
067c20608c | ||
|
|
6ec4c4bf8f | ||
|
|
87f432880a | ||
|
|
9624d70187 | ||
|
|
3026367c1b | ||
|
|
da2323f80e | ||
|
|
f85807a2a6 | ||
|
|
1a47aec6e4 | ||
|
|
fd15704c77 | ||
|
|
a05916bee8 | ||
|
|
91a6f721a3 | ||
|
|
3eef200145 | ||
|
|
d6d84ce349 | ||
|
|
42ead1499f | ||
|
|
d0a20cadaf | ||
|
|
dac3b675cc | ||
|
|
6aac3184c3 | ||
|
|
a06b6c807e | ||
|
|
e6a7429ac7 | ||
|
|
e251ac4f74 | ||
|
|
65a11095c0 | ||
|
|
79b3abd797 | ||
|
|
37389005fc | ||
|
|
6904a79d1d | ||
|
|
62bf779c14 | ||
|
|
5c13dcdb7b | ||
|
|
706cbe89ec | ||
|
|
a6a469435a | ||
|
|
834e2b82f3 | ||
|
|
805969b598 | ||
|
|
18bc9db6b3 | ||
|
|
258184232d | ||
|
|
6156402c1a | ||
|
|
9279795c47 | ||
|
|
d0450bb425 | ||
|
|
1cfe409a09 | ||
|
|
cfcff68e91 | ||
|
|
d588bb00d4 | ||
|
|
e74656fa71 | ||
|
|
d64ed620d5 | ||
|
|
377885bd36 | ||
|
|
cf1e0d743c | ||
|
|
b23ce86c81 | ||
|
|
cae8e8d6e8 | ||
|
|
24af8adcf9 | ||
|
|
c3083f0186 | ||
|
|
083877d286 | ||
|
|
044ea90dde | ||
|
|
9c9f89e9be | ||
|
|
401f2a77e3 | ||
|
|
b1ddf1f048 | ||
|
|
51725a71a3 | ||
|
|
f146c9ef16 | ||
|
|
abfd5719d6 | ||
|
|
3a0cc3c927 | ||
|
|
40fcfc9479 | ||
|
|
cac467a2df | ||
|
|
6ef9fc64d7 | ||
|
|
ee70a1d1fb | ||
|
|
1478473537 | ||
|
|
c82ebd3ef3 | ||
|
|
1838582546 | ||
|
|
d372fac9c6 | ||
|
|
cef13aa705 | ||
|
|
f4ec53dcb9 | ||
|
|
68ad27e31c | ||
|
|
613d866296 | ||
|
|
4658f937cc | ||
|
|
67af3c3291 | ||
|
|
98b875cd0f | ||
|
|
7d9300e0f5 | ||
|
|
c892fd174e | ||
|
|
08caf7b9fc | ||
|
|
4381b03412 | ||
|
|
d099dabf37 | ||
|
|
d0b06b4334 | ||
|
|
be48233bc4 | ||
|
|
fd9e2d3def | ||
|
|
c643ce2a7a | ||
|
|
36bdec0f2c | ||
|
|
8341b662af | ||
|
|
e8d75a39bc | ||
|
|
02b945cc95 | ||
|
|
c8b15af979 | ||
|
|
eb73b4e58e | ||
|
|
7d518e336e | ||
|
|
09b602b4ec | ||
|
|
4ffbd9802a | ||
|
|
1eecce9a15 | ||
|
|
66bbb723c5 | ||
|
|
8de1c449ee | ||
|
|
facf5c09a0 | ||
|
|
b3e0fafe50 | ||
|
|
96149d1f71 | ||
|
|
014a4d51a6 | ||
|
|
922ca2ee1c | ||
|
|
480aa406bb | ||
|
|
43848b7b43 | ||
|
|
8112b276c0 | ||
|
|
f436808735 | ||
|
|
7957196924 | ||
|
|
25babbfdc4 | ||
|
|
277225dc45 | ||
|
|
d3ca84e14c | ||
|
|
07430eb33d | ||
|
|
9af5b13803 | ||
|
|
cfb369d727 | ||
|
|
2679768447 | ||
|
|
d746304371 | ||
|
|
f5cc6bb283 | ||
|
|
6177c2d575 | ||
|
|
616293f8a7 | ||
|
|
d77dee50c9 | ||
|
|
c4c0f1349a | ||
|
|
17a7d4e8dd | ||
|
|
2aeeeff65f | ||
|
|
d17141b859 | ||
|
|
1afa48fcdf | ||
|
|
7cb9e95a53 | ||
|
|
407a3c2c10 | ||
|
|
5f4eb8b509 | ||
|
|
d28c266771 | ||
|
|
d5b826ffc8 | ||
|
|
59d942c9ec | ||
|
|
92b792b3f0 | ||
|
|
7dcf19d902 | ||
|
|
f7e8cd8ac8 | ||
|
|
56e77f6843 | ||
|
|
e24e0cf364 | ||
|
|
3133c7c84e | ||
|
|
b85854d0fe | ||
|
|
4b7d9c72df | ||
|
|
e612aedbff | ||
|
|
80ff2dc77d | ||
|
|
c228df8f90 | ||
|
|
fd3cbd96a8 | ||
|
|
cb96deb517 | ||
|
|
f58b3d082f | ||
|
|
090d16392b | ||
|
|
760e9b3df5 | ||
|
|
63f5fa47de | ||
|
|
fd535a50d3 | ||
|
|
e9217181c1 | ||
|
|
2c5ec94843 | ||
|
|
fc7580ab5e | ||
|
|
adb3bc2577 | ||
|
|
3e400ff9f2 | ||
|
|
a596f32a8e | ||
|
|
944f15e401 | ||
|
|
02aeff8efc | ||
|
|
b5c32a4c79 | ||
|
|
6f91bcafdb | ||
|
|
576f8d4681 | ||
|
|
cb86d0d6d4 | ||
|
|
f6f178ddee | ||
|
|
b3d640b978 | ||
|
|
0fc7a06913 | ||
|
|
e38cdb3133 | ||
|
|
b21e62f072 | ||
|
|
b0672da396 | ||
|
|
c2d185aab7 | ||
|
|
bfadc8f4ee | ||
|
|
1716c01bdf | ||
|
|
975aa5bf82 | ||
|
|
7b81d97ec2 | ||
|
|
81beda0772 | ||
|
|
82b342e77b | ||
|
|
4166cd12b6 | ||
|
|
ac922e83d3 | ||
|
|
dc6f22c2c5 | ||
|
|
897685a2de | ||
|
|
a0bb2bccaf | ||
|
|
7b79823b24 | ||
|
|
17a7dfc966 | ||
|
|
22d517a520 | ||
|
|
201c879772 | ||
|
|
7883491ce2 | ||
|
|
7f02b62bba | ||
|
|
b0c97d6178 | ||
|
|
1c757ae35e | ||
|
|
81c55be19b | ||
|
|
dd0104290e | ||
|
|
6ca34c1259 | ||
|
|
c3932c8508 | ||
|
|
00b77421dd | ||
|
|
bcd52ee546 | ||
|
|
75927d736a | ||
|
|
5e78d5a21f | ||
|
|
ae6f268987 | ||
|
|
08cceb6435 | ||
|
|
6b107e9e74 | ||
|
|
6d223303eb | ||
|
|
8875dbd449 | ||
|
|
475d598ecb | ||
|
|
e55358c65d | ||
|
|
a2bba7ef51 | ||
|
|
7f326ae4ae | ||
|
|
13b47e6047 | ||
|
|
0bc50abd73 | ||
|
|
c469fac8ef | ||
|
|
a542a285a3 | ||
|
|
eb908985f7 | ||
|
|
1df7bfefe7 | ||
|
|
9f9b6044aa | ||
|
|
73c1d11eb9 | ||
|
|
ea1cd2c734 | ||
|
|
54d82e4e83 | ||
|
|
3cb3460398 | ||
|
|
96b4d885ac | ||
|
|
678d704341 | ||
|
|
15e6761035 | ||
|
|
e67ca92443 | ||
|
|
2ab5890eab | ||
|
|
d6256a388e | ||
|
|
04b1eb57eb | ||
|
|
8d096ef85d | ||
|
|
a2bab7d923 | ||
|
|
81f81be816 | ||
|
|
269a3c4000 | ||
|
|
2c7d5c82f3 | ||
|
|
7d72fcf7f8 | ||
|
|
e0812f8c4d | ||
|
|
1c73d45106 | ||
|
|
951789e9fa | ||
|
|
d9cbecac7f | ||
|
|
7274d6e757 | ||
|
|
0bb9756e0c | ||
|
|
8e02c53df2 | ||
|
|
051da852a2 | ||
|
|
3b53a84459 | ||
|
|
01e737e90e | ||
|
|
fe69bc9439 | ||
|
|
d258c68ca1 | ||
|
|
750dea3edf | ||
|
|
5f4df5e336 | ||
|
|
0c167e85af | ||
|
|
e0c310d056 | ||
|
|
827e68eadd | ||
|
|
3db52c972d | ||
|
|
17ccf53eb1 | ||
|
|
5b50c97939 | ||
|
|
89132fdd25 | ||
|
|
8dd2286e3e | ||
|
|
aa64abe84c | ||
|
|
9998293dff | ||
|
|
8d67bd2889 | ||
|
|
f9be9ad426 | ||
|
|
7ed53e243d | ||
|
|
0a31112fb4 | ||
|
|
732972be2b | ||
|
|
721183e259 | ||
|
|
77d4bb8dfe | ||
|
|
a4d6638f89 | ||
|
|
c32e3c467d | ||
|
|
185727c696 | ||
|
|
37cbcc97d3 | ||
|
|
eced473e05 | ||
|
|
af1f6fab29 | ||
|
|
8808d8c84c | ||
|
|
266643bb94 | ||
|
|
2062165cd3 | ||
|
|
c807c7e72d | ||
|
|
581da80209 | ||
|
|
394027dffb | ||
|
|
60b282cf1d | ||
|
|
c46bab35df | ||
|
|
27952bb94d | ||
|
|
95c2ccbd7b | ||
|
|
458cbcfb41 | ||
|
|
0a026fea1c | ||
|
|
db22207014 | ||
|
|
48a1b07097 | ||
|
|
8e35ad5484 | ||
|
|
482364aa92 | ||
|
|
3aa5f45094 | ||
|
|
31a2830f7d | ||
|
|
133124e023 | ||
|
|
7842109609 | ||
|
|
f9fe067f68 | ||
|
|
9114331218 | ||
|
|
aa5e75e853 | ||
|
|
333832c2e1 | ||
|
|
a69a863090 | ||
|
|
a4806e9417 | ||
|
|
ef1ce5d9a8 | ||
|
|
5d34e27078 | ||
|
|
64fc5fa9fc | ||
|
|
16f893c46b | ||
|
|
7e4d5c9f84 | ||
|
|
1be8a39442 | ||
|
|
709d5d9cd6 | ||
|
|
9a8fe4d683 | ||
|
|
1309cee124 | ||
|
|
8aa80fa464 | ||
|
|
8e27ea7371 | ||
|
|
69546d563d | ||
|
|
16cfd24967 | ||
|
|
b97a9235e0 | ||
|
|
3e19f82af2 | ||
|
|
d8a23cf5ab | ||
|
|
6c8b2a8dc9 | ||
|
|
875b849322 | ||
|
|
2e1dee197a | ||
|
|
b3b84ffefa | ||
|
|
e52a2888cc | ||
|
|
0cbc5fea93 | ||
|
|
dc81d0a649 | ||
|
|
75d193a284 | ||
|
|
3c79d5c711 | ||
|
|
151523f47b | ||
|
|
dfbee10377 | ||
|
|
1a295d9460 | ||
|
|
84f668f9c5 | ||
|
|
468889abef | ||
|
|
8a3e100ad1 | ||
|
|
fb1ec2cfbf | ||
|
|
362f62f325 | ||
|
|
3b78870f33 | ||
|
|
c678216ac2 | ||
|
|
4827c283a6 | ||
|
|
0a722de65c | ||
|
|
3a99ac7e9a | ||
|
|
efa5f0bfe0 | ||
|
|
e1789ba9e5 | ||
|
|
302d51fd40 | ||
|
|
340d5d03f2 | ||
|
|
7bb0841f42 | ||
|
|
a738ade0ec | ||
|
|
514b85ba33 | ||
|
|
0dbb569187 | ||
|
|
75af0e61da | ||
|
|
b8afbfbb44 | ||
|
|
14096fb629 | ||
|
|
9c33080f12 | ||
|
|
97635a8966 | ||
|
|
f2246df875 | ||
|
|
a52e75d978 | ||
|
|
7f12548615 | ||
|
|
57d3d6bd78 | ||
|
|
455723375e | ||
|
|
8ee7223587 | ||
|
|
ae9f5ecc2d | ||
|
|
948ce5eb5f | ||
|
|
bcec534e5e | ||
|
|
4ca2677815 | ||
|
|
6484195bfe | ||
|
|
daafa9e3bc | ||
|
|
8f21f34b1c | ||
|
|
94c61aa19d | ||
|
|
e84cafec8a | ||
|
|
bd4bc16192 | ||
|
|
ab1896dc13 | ||
|
|
9b901889ee | ||
|
|
debdd2aa95 | ||
|
|
714e170c16 | ||
|
|
a5f0f62e0d | ||
|
|
e5dbba7b67 | ||
|
|
7a0614c850 | ||
|
|
27adabbeea | ||
|
|
f666f60731 | ||
|
|
43545a4864 | ||
|
|
4e92ccc0dd | ||
|
|
71309c064a | ||
|
|
01b8f4582f | ||
|
|
e09708e82d | ||
|
|
561fa99d95 | ||
|
|
7ece3717e6 | ||
|
|
e952f7df96 | ||
|
|
a5cb51282c | ||
|
|
e0439df4ce | ||
|
|
7c7858a519 | ||
|
|
9e9f2babeb | ||
|
|
490bbd10fc | ||
|
|
829782c42c | ||
|
|
db7ea832a7 | ||
|
|
12231e3f59 | ||
|
|
df1fdbeb2b | ||
|
|
9f8336c92b | ||
|
|
769b76cd40 | ||
|
|
69f8af530d | ||
|
|
41d484d239 | ||
|
|
2f1ce51b2c | ||
|
|
4360d1106d | ||
|
|
4655360546 | ||
|
|
89d01f15a5 | ||
|
|
b1d4bd0f78 | ||
|
|
6f6af83033 | ||
|
|
dc9fdf335d | ||
|
|
fb0c566b2a | ||
|
|
cdfae0b9d3 | ||
|
|
3965c5b4d2 | ||
|
|
ed80860c34 | ||
|
|
1f215848be | ||
|
|
dc1992cbb5 | ||
|
|
b01d7e39d5 | ||
|
|
d38a8d7076 | ||
|
|
36fa3c3cd3 | ||
|
|
1cf8503017 | ||
|
|
95015ddea6 | ||
|
|
cd2f3bd355 | ||
|
|
a33271d374 | ||
|
|
7392387ec1 | ||
|
|
a6c309824e | ||
|
|
88dca1afdf | ||
|
|
b48ed56635 | ||
|
|
014667e00b | ||
|
|
cda2025c49 | ||
|
|
59f89678b2 | ||
|
|
9a1267b530 | ||
|
|
5b46214379 | ||
|
|
5939dff092 | ||
|
|
ab28eb5109 | ||
|
|
2c7d64232e | ||
|
|
5965708f1d | ||
|
|
d58cdf7c9f | ||
|
|
35ba99c245 | ||
|
|
e75ca23e7d | ||
|
|
5391f1141e | ||
|
|
699ba410fe | ||
|
|
da48a9907b | ||
|
|
53b81d07c6 | ||
|
|
2fae8eda66 | ||
|
|
884e734809 | ||
|
|
13ddd40a59 | ||
|
|
d55750189e | ||
|
|
2d4ec35e1c | ||
|
|
712a7dddf6 | ||
|
|
10e46c6d5e | ||
|
|
0373574f0b | ||
|
|
88039a69a2 | ||
|
|
ea748f9440 | ||
|
|
a574ae5415 | ||
|
|
23e34dd598 | ||
|
|
01641b34ea | ||
|
|
d0c4ce6749 | ||
|
|
58a9e35233 | ||
|
|
5fea53d89c | ||
|
|
2afb75d508 | ||
|
|
7cffa65376 | ||
|
|
f63496b389 | ||
|
|
9632ebed7b | ||
|
|
3a8d8bc6a4 | ||
|
|
634f1389a4 | ||
|
|
7363bd1ad7 | ||
|
|
0c74ef25d6 | ||
|
|
5a2242ee92 | ||
|
|
31fb5867e8 | ||
|
|
e90c00768f | ||
|
|
dc4c174f4e | ||
|
|
7905a27416 | ||
|
|
965615a46c | ||
|
|
04e0e10411 | ||
|
|
28e3e51335 | ||
|
|
5372c1ab54 | ||
|
|
871c9e5286 | ||
|
|
9d42972b8a | ||
|
|
4dac298ae2 | ||
|
|
2f9306b98a | ||
|
|
28a0864de8 | ||
|
|
4a918ccf47 | ||
|
|
4082b90aa4 | ||
|
|
491f928a2e | ||
|
|
265ef6d0c6 | ||
|
|
9052160af3 | ||
|
|
f1bc178141 | ||
|
|
29e9a574b0 | ||
|
|
ba19173b96 | ||
|
|
393c414e90 | ||
|
|
bf67b29a0e | ||
|
|
43e0462405 | ||
|
|
7866203c5c | ||
|
|
d1ccdeb5f5 | ||
|
|
c54f2a122a | ||
|
|
b7c900739e | ||
|
|
d05de07fdd | ||
|
|
cc94db458c | ||
|
|
7450aed663 | ||
|
|
5fe39307cd | ||
|
|
713eb898f6 | ||
|
|
8445c9a5e8 | ||
|
|
559e175b38 | ||
|
|
3149d6d331 | ||
|
|
ecd4c9c4f5 | ||
|
|
c20a12aa7b | ||
|
|
1f7dbb6a48 | ||
|
|
4c5440ccab | ||
|
|
a564558a67 | ||
|
|
51c2d29693 | ||
|
|
8ed412db22 | ||
|
|
e210069dbd | ||
|
|
4dde870121 | ||
|
|
959a19d8ae | ||
|
|
63bad210c9 | ||
|
|
4c09acb2a9 | ||
|
|
874c7465a5 | ||
|
|
e7b3cc3d3d | ||
|
|
fd04bdb9cf | ||
|
|
63ff5819b1 | ||
|
|
03605bfa6a | ||
|
|
d42444928b | ||
|
|
a483e58860 | ||
|
|
f24a4626e3 | ||
|
|
aa34d7d5f2 | ||
|
|
af20abf78d | ||
|
|
390b102563 | ||
|
|
908fb94f0b | ||
|
|
8803787e48 | ||
|
|
a851444a1d | ||
|
|
e8698fb9ef | ||
|
|
840c64084e | ||
|
|
af2accf5e3 | ||
|
|
fa501e7730 | ||
|
|
dab3b3442f | ||
|
|
c410e2fa47 | ||
|
|
2567281846 | ||
|
|
73988506fe | ||
|
|
e6f8e1e531 | ||
|
|
4019fafc2c | ||
|
|
21ef1761c6 | ||
|
|
f6b1dc452a | ||
|
|
a54442866a | ||
|
|
b80b3a38bf | ||
|
|
1232eb6f5e | ||
|
|
712cfa94aa | ||
|
|
bfd3eb46c7 | ||
|
|
bce3cc992f | ||
|
|
6c9dd731a9 | ||
|
|
0b611fc354 | ||
|
|
871e888283 |
No files matched your search
@@ -5,8 +5,59 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
install-check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Capture node path
|
||||
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)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -54,7 +105,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
@@ -85,13 +136,13 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --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 }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-windows:
|
||||
runs-on: windows-latest
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -133,7 +184,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Bun
|
||||
@@ -164,11 +215,72 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --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 }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
|
||||
- name: Capture node path
|
||||
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
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
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 }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
@@ -412,7 +524,7 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Workflow Sanity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(".github/workflows")
|
||||
bad: list[str] = []
|
||||
for path in sorted(root.rglob("*.yml")):
|
||||
if b"\t" in path.read_bytes():
|
||||
bad.append(str(path))
|
||||
|
||||
for path in sorted(root.rglob("*.yaml")):
|
||||
if b"\t" in path.read_bytes():
|
||||
bad.append(str(path))
|
||||
|
||||
if bad:
|
||||
print("Tabs found in workflow file(s):")
|
||||
for path in bad:
|
||||
print(f"- {path}")
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
@@ -50,3 +51,7 @@ apps/ios/*.dSYM.zip
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
@@ -17,6 +17,8 @@ excluded:
|
||||
- dist
|
||||
- coverage
|
||||
- "*.playground"
|
||||
# Generated (protocol-gen-swift.ts)
|
||||
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
|
||||
|
||||
analyzer_rules:
|
||||
- unused_declaration
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
- 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)`).
|
||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
@@ -28,11 +29,14 @@
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||
- 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`.
|
||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||
|
||||
@@ -40,6 +44,7 @@
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR 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`.
|
||||
@@ -62,6 +67,11 @@
|
||||
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
@@ -80,24 +90,28 @@
|
||||
- **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:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- 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.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||
- Voice wake forwarding tips:
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
|
||||
## Exclamation Mark Escaping Workaround
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
|
||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||
|
||||
```bash
|
||||
# WRONG - will send "Hello\\!" with backslash
|
||||
clawdbot send --to "+1234" --message 'Hello!'
|
||||
clawdbot message send --to "+1234" --message 'Hello!'
|
||||
|
||||
# CORRECT - use heredoc to avoid escaping
|
||||
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||
Hello!
|
||||
EOF
|
||||
)"
|
||||
|
||||
@@ -1,8 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 2026.1.10
|
||||
|
||||
- (nothing yet)
|
||||
### Highlights
|
||||
- CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).
|
||||
- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.
|
||||
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
|
||||
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete.
|
||||
|
||||
### Changes
|
||||
- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag.
|
||||
- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.
|
||||
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
|
||||
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
|
||||
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
|
||||
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
|
||||
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete.
|
||||
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
|
||||
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
||||
|
||||
### Fixes
|
||||
- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).
|
||||
- CLI/Gateway: clarify that `clawdbot gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.
|
||||
- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.
|
||||
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
|
||||
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
|
||||
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
|
||||
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
|
||||
- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.
|
||||
- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.
|
||||
- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons.
|
||||
- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.
|
||||
- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).
|
||||
- Docs: make `clawdbot status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`.
|
||||
- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids.
|
||||
- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.
|
||||
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
|
||||
- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.
|
||||
- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.
|
||||
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
|
||||
- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.
|
||||
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
|
||||
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
|
||||
- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output.
|
||||
- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.
|
||||
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
|
||||
- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.
|
||||
- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.
|
||||
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
|
||||
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.
|
||||
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
|
||||
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
|
||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.
|
||||
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
|
||||
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
|
||||
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
|
||||
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
|
||||
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
|
||||
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.
|
||||
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
|
||||
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
|
||||
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
|
||||
- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.
|
||||
- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.
|
||||
- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`).
|
||||
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
|
||||
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
|
||||
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
|
||||
- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646)
|
||||
- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest).
|
||||
- Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660)
|
||||
- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.
|
||||
- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75.
|
||||
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
|
||||
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
|
||||
- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).
|
||||
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
|
||||
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
|
||||
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
|
||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
||||
- Telegram: serialize media-group processing to avoid missed albums under load.
|
||||
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
|
||||
- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.
|
||||
- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).
|
||||
- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).
|
||||
|
||||
|
||||
## 2026.1.9
|
||||
|
||||
### Highlights
|
||||
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
|
||||
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
|
||||
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
|
||||
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
|
||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
||||
|
||||
### Breaking
|
||||
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### New Features and Changes
|
||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
||||
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
|
||||
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
|
||||
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
|
||||
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
|
||||
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
|
||||
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
|
||||
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
|
||||
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
|
||||
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
|
||||
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
|
||||
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
|
||||
- Providers: Signal reactions + notifications with allowlist support.
|
||||
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
|
||||
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
|
||||
- TUI: agent picker + agents list RPC; improved status line.
|
||||
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
|
||||
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
|
||||
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
|
||||
|
||||
### Fixes
|
||||
- Packaging: include MS Teams send module in npm tarball.
|
||||
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
|
||||
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
|
||||
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
|
||||
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
|
||||
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
|
||||
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
|
||||
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
|
||||
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
|
||||
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
|
||||
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
|
||||
- iMessage: isolate group-ish threads by chat_id.
|
||||
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
||||
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
|
||||
### Maintenance
|
||||
- Dependencies: bump pi-* stack to 0.42.2.
|
||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||
|
||||
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||
|
||||
## 2026.1.8
|
||||
|
||||
@@ -19,7 +162,7 @@
|
||||
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
|
||||
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
|
||||
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
- Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`).
|
||||
- Approve requests via `clawdbot pairing list <provider>` + `clawdbot pairing approve <provider> <code>`.
|
||||
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
|
||||
@@ -8,9 +8,22 @@ RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
@@ -14,6 +14,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
novnc \
|
||||
python3 \
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||
# 🦞 Clawdbot — Personal AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -37,25 +37,18 @@ Model note: while any model is supported, I strongly recommend **Anthropic Pro/M
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
|
||||
|
||||
## Recommended setup (from source)
|
||||
## Install (recommended)
|
||||
|
||||
Do **not** download prebuilt binaries. Run from source.
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
```bash
|
||||
# Clone this repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
npm install -g clawdbot@latest
|
||||
# or: pnpm add -g clawdbot@latest
|
||||
|
||||
pnpm install
|
||||
pnpm ui:install
|
||||
pnpm ui:build
|
||||
pnpm build
|
||||
pnpm clawdbot onboard
|
||||
clawdbot onboard --install-daemon
|
||||
```
|
||||
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
@@ -64,23 +57,38 @@ Runtime: **Node ≥22**.
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
|
||||
|
||||
```bash
|
||||
pnpm clawdbot onboard
|
||||
clawdbot onboard --install-daemon
|
||||
|
||||
pnpm clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
clawdbot gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
|
||||
|
||||
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm clawdbot onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
@@ -90,7 +98,7 @@ Full security guide: [Security](https://docs.clawd.bot/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Approve with: `clawdbot pairing approve <provider> <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 provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
|
||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
@@ -151,15 +159,16 @@ WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │ ws://127.0.0.1:18789
|
||||
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (clawdbot …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS/Android nodes
|
||||
└─ iOS / Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
@@ -231,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
@@ -275,7 +285,7 @@ Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
|
||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||
|
||||
@@ -296,14 +306,14 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
## Security model (important)
|
||||
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/whatsapp)
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Link the device: `pnpm clawdbot providers login` (stores creds in `~/.clawdbot/credentials`).
|
||||
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
@@ -443,17 +453,24 @@ by Peter Steinberger and the community.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/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/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/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||
<a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></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/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/search?q=Tobias%20Bischoff"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/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/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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/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/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/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/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/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/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/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/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/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/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></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/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/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/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/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/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/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/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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||
<a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/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/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=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/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -1,55 +1,78 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
|
||||
<title>2026.1.9</title>
|
||||
<pubDate>Sat, 10 Jan 2026 05:23:02 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3095</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:version>4607</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.9</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.</li>
|
||||
<li>Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.</li>
|
||||
<li>CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.</li>
|
||||
<li>Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.</li>
|
||||
<li>Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.</li>
|
||||
<li>Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>CLI: <code>clawdbot message</code> now subcommands (<code>message send|poll|...</code>) and requires <code>--provider</code> unless only one provider configured.</li>
|
||||
<li>Commands/Tools: <code>/restart</code> and gateway restart tool disabled by default; enable with <code>commands.restart=true</code>.</li>
|
||||
</ul>
|
||||
<h3>New Features and Changes</h3>
|
||||
<ul>
|
||||
<li>Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.</li>
|
||||
<li>Models/Auth: setup-token + token auth profiles; <code>clawdbot models auth order {get,set,clear}</code>; per-agent auth candidates in <code>/model status</code>; OAuth expiry checks in doctor/status.</li>
|
||||
<li>Agent/System: claude-cli runner; <code>session_status</code> tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.</li>
|
||||
<li>Commands: <code>/commands</code> list; <code>/models</code> alias; <code>/usage</code> alias; <code>/debug</code> runtime overrides + effective config view; <code>/config</code> chat updates + <code>/config get</code>; <code>config --section</code>.</li>
|
||||
<li>CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.</li>
|
||||
<li>CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global <code>--no-color</code>; lobster palette in onboarding/config.</li>
|
||||
<li>Dev ergonomics: gateway <code>--dev/--reset</code> + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.</li>
|
||||
<li>Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.</li>
|
||||
<li>Config/Onboarding: inline env vars; OpenAI API key flow to shared <code>~/.clawdbot/.env</code>; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (<code>tui --message</code>); remove Bun runtime choice.</li>
|
||||
<li>Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc</li>
|
||||
<li>Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.</li>
|
||||
<li>Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; <code>[[audio_as_voice]]</code> tag support (#490) — thanks @jarvis-medmatic.</li>
|
||||
<li>Providers: Signal reactions + notifications with allowlist support.</li>
|
||||
<li>Status/Usage: /status cost reporting + <code>/cost</code> lines; auth profile snippet; provider usage windows.</li>
|
||||
<li>Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit <code>ui:install</code>.</li>
|
||||
<li>TUI: agent picker + agents list RPC; improved status line.</li>
|
||||
<li>Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.</li>
|
||||
<li>Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.</li>
|
||||
<li>Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
<li>Packaging: include MS Teams send module in npm tarball.</li>
|
||||
<li>Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.</li>
|
||||
<li>Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.</li>
|
||||
<li>Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.</li>
|
||||
<li>Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.</li>
|
||||
<li>Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.</li>
|
||||
<li>WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.</li>
|
||||
<li>Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.</li>
|
||||
<li>Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.</li>
|
||||
<li>Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.</li>
|
||||
<li>MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.</li>
|
||||
<li>iMessage: isolate group-ish threads by chat_id.</li>
|
||||
<li>Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.</li>
|
||||
<li>Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.</li>
|
||||
<li>Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.</li>
|
||||
</ul>
|
||||
<h3>Maintenance</h3>
|
||||
<ul>
|
||||
<li>Dependencies: bump pi-* stack to 0.42.2.</li>
|
||||
<li>Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.</li>
|
||||
<li>Build: Docker build cache layer (#605) — thanks @zknicker.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3091</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3089</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.9/Clawdbot-2026.1.9.zip" length="164165451" type="application/octet-stream" sparkle:edSignature="jlfk60gLuMIEOru5kFe7K7lLwsx1RBZGdUnkOR7FR3GAfLum7ocUb+QUO0ZtllUoGPEK0N1yc5BLB702LgLtAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 20260108
|
||||
versionName = "2026.1.8"
|
||||
versionCode = 20260109
|
||||
versionName = "2026.1.9"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -49,6 +49,7 @@ android {
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -72,6 +73,7 @@ androidComponents {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +102,7 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -700,8 +700,8 @@ class NodeRuntime(context: Context) {
|
||||
val ui = config?.get("ui").asObjectOrNull()
|
||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
|
||||
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
_mainSessionKey.value = mainKey
|
||||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
internal fun normalizeMainKey(raw: String?): String {
|
||||
val trimmed = raw?.trim()
|
||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.ExifInterface
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
|
||||
@@ -122,13 +122,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
|
||||
|
||||
private suspend fun ensureMicPermission() {
|
||||
val granted =
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.clawdbot.android.bridge.BridgeSession
|
||||
import com.clawdbot.android.normalizeMainKey
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
@@ -814,7 +815,7 @@ class TalkModeManager(
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val aliases =
|
||||
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 46 KiB |
@@ -161,18 +161,10 @@ actor BridgeClient {
|
||||
purpose: String,
|
||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await op()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: Double(seconds),
|
||||
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
|
||||
operation: op)
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||
|
||||
@@ -115,7 +115,11 @@ final class BridgeConnectionController {
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +136,11 @@ final class BridgeConnectionController {
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
@@ -171,7 +179,12 @@ final class BridgeConnectionController {
|
||||
"bridge-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -192,7 +205,10 @@ final class BridgeConnectionController {
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
|
||||
@@ -321,20 +321,10 @@ actor BridgeSession {
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
guard let first = try await group.next() else {
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: seconds,
|
||||
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
|
||||
operation: operation)
|
||||
}
|
||||
|
||||
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>20260109</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -35,10 +35,10 @@
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -86,24 +86,11 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T>(
|
||||
private func withTimeout<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
||||
@@ -117,26 +104,35 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let locs = locations
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
let err = error
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,12 +204,14 @@ final class NodeAppModel {
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
|
||||
@@ -330,8 +332,7 @@ final class NodeAppModel {
|
||||
let ui = config["ui"] as? [String: Any]
|
||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let session = config["session"] as? [String: Any]
|
||||
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
await MainActor.run {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionKey = mainKey
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
enum SessionKey {
|
||||
static func normalizeMainKey(_ raw: String?) -> String {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,7 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: bridge.endpoint,
|
||||
bridgeStableID: bridge.stableID,
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
@@ -499,6 +500,7 @@ struct SettingsTab: View {
|
||||
|
||||
self.appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
hello: BridgeHello(
|
||||
nodeId: self.instanceId,
|
||||
displayName: self.displayName,
|
||||
|
||||
@@ -288,9 +288,8 @@ final class TalkModeManager: NSObject {
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
|
||||
"err=\(error.localizedDescription, privacy: .public)")
|
||||
let err = error.localizedDescription
|
||||
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,9 +527,8 @@ final class TalkModeManager: NSObject {
|
||||
self.lastPlaybackWasPCM = false
|
||||
result = await self.mp3Player.play(stream: stream)
|
||||
}
|
||||
self.logger.info(
|
||||
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
|
||||
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
||||
let duration = Date().timeIntervalSince(started)
|
||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.lastInterruptedAtSeconds = interruptedAt
|
||||
}
|
||||
@@ -651,8 +649,7 @@ final class TalkModeManager: NSObject {
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let session = config["session"] as? [String: Any]
|
||||
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||
self.mainSessionKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
|
||||
@@ -175,7 +175,6 @@ private func withKeychainValues<T>(
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||
let defaults = UserDefaults.standard
|
||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>20260109</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,6 +5,10 @@ options:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
|
||||
packages:
|
||||
ClawdbotKit:
|
||||
path: ../shared/ClawdbotKit
|
||||
@@ -68,11 +72,15 @@ targets:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
|
||||
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -84,8 +92,20 @@ targets:
|
||||
NSBonjourServices:
|
||||
- _clawdbot-bridge._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition for voice wake.
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
|
||||
ClawdbotTests:
|
||||
type: bundle.unit-test
|
||||
@@ -96,13 +116,17 @@ targets:
|
||||
- target: Clawdbot
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
info:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -10,7 +10,9 @@ let package = Package(
|
||||
],
|
||||
products: [
|
||||
.library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]),
|
||||
.library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]),
|
||||
.executable(name: "Clawdbot", targets: ["Clawdbot"]),
|
||||
.executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -36,10 +38,20 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotDiscovery",
|
||||
dependencies: [
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
],
|
||||
path: "Sources/ClawdbotDiscovery",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "Clawdbot",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "ClawdbotKit", package: "ClawdbotKit"),
|
||||
.product(name: "ClawdbotChatUI", package: "ClawdbotKit"),
|
||||
@@ -61,11 +73,21 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdbotIPCTests",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"Clawdbot",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
|
||||
@@ -29,7 +29,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
var launchAtLogin: Bool {
|
||||
didSet { self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } } }
|
||||
didSet {
|
||||
guard !self.isInitializing else { return }
|
||||
self.ifNotPreview { Task { AppStateStore.updateLaunchAtLogin(enabled: self.launchAtLogin) } }
|
||||
}
|
||||
}
|
||||
|
||||
var onboardingSeen: Bool {
|
||||
|
||||
@@ -81,22 +81,33 @@ enum ClawdbotConfigFile {
|
||||
|
||||
static func agentWorkspace() -> String? {
|
||||
let root = self.loadDict()
|
||||
let agent = root["agent"] as? [String: Any]
|
||||
return agent?["workspace"] as? String
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
return defaults?["workspace"] as? String
|
||||
}
|
||||
|
||||
static func setAgentWorkspace(_ workspace: String?) {
|
||||
var root = self.loadDict()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
agent.removeValue(forKey: "workspace")
|
||||
defaults.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
agent["workspace"] = trimmed
|
||||
defaults["workspace"] = trimmed
|
||||
}
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
root["agent"] = agent
|
||||
self.saveDict(root)
|
||||
self.logger.debug("agent workspace updated set=\(!trimmed.isEmpty)")
|
||||
self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
|
||||
}
|
||||
|
||||
static func gatewayPassword() -> String? {
|
||||
|
||||
@@ -12,11 +12,12 @@ struct ConfigSettings: View {
|
||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||
+ "so it won’t interfere with your daily browser."
|
||||
@State private var configModel: String = ""
|
||||
@State private var customModel: String = ""
|
||||
@State private var configSaving = false
|
||||
@State private var hasLoaded = false
|
||||
@State private var models: [ModelChoice] = []
|
||||
@State private var modelsLoading = false
|
||||
@State private var modelSearchQuery: String = ""
|
||||
@State private var isModelPickerOpen = false
|
||||
@State private var modelError: String?
|
||||
@State private var modelsSourceLabel: String?
|
||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||
@@ -36,10 +37,10 @@ struct ConfigSettings: View {
|
||||
@State private var talkInterruptOnSpeech: Bool = true
|
||||
@State private var talkApiKey: String = ""
|
||||
@State private var gatewayApiKeyFound = false
|
||||
@FocusState private var modelSearchFocused: Bool
|
||||
|
||||
private struct ConfigDraft {
|
||||
let configModel: String
|
||||
let customModel: String
|
||||
let heartbeatMinutes: Int?
|
||||
let heartbeatBody: String
|
||||
let browserEnabled: Bool
|
||||
@@ -106,8 +107,7 @@ struct ConfigSettings: View {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelPickerField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
@@ -116,37 +116,114 @@ struct ConfigSettings: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var modelPicker: some View {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
private var modelPickerField: some View {
|
||||
Button {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.isModelPickerOpen = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.modelPickerLabel)
|
||||
.foregroundStyle(self.modelPickerLabelIsPlaceholder ? .secondary : .primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(
|
||||
Color(nsColor: .textBackgroundColor)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(
|
||||
Color.secondary.opacity(0.25),
|
||||
lineWidth: 1))
|
||||
.popover(isPresented: self.$isModelPickerOpen, arrowEdge: .bottom) {
|
||||
self.modelPickerPopover
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
.onChange(of: self.isModelPickerOpen) { _, isOpen in
|
||||
if isOpen {
|
||||
self.modelSearchQuery = ""
|
||||
self.modelSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customModelField: some View {
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
private var modelPickerPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Search models", text: self.$modelSearchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
.focused(self.$modelSearchFocused)
|
||||
.controlSize(.small)
|
||||
.onSubmit {
|
||||
if let exact = self.exactMatchForQuery() {
|
||||
self.selectModel(exact)
|
||||
return
|
||||
}
|
||||
if let manual = self.manualEntryCandidate {
|
||||
self.selectManualModel(manual)
|
||||
return
|
||||
}
|
||||
if self.modelSearchMatches.count == 1 {
|
||||
self.selectModel(self.modelSearchMatches[0])
|
||||
}
|
||||
}
|
||||
List {
|
||||
if self.modelSearchMatches.isEmpty {
|
||||
Text("No models match \"\(self.modelSearchQuery)\"")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.modelSearchMatches) { choice in
|
||||
Button {
|
||||
self.selectModel(choice)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Text(choice.name)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(choice.provider.uppercased())
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6)
|
||||
.background(Color.secondary.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
|
||||
if let manual = self.manualEntryCandidate {
|
||||
Button("Use \"\(manual)\"") {
|
||||
self.selectManualModel(manual)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
.frame(width: 340, height: 260)
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if self.shouldShowProviderHintForSelection {
|
||||
self.statusLine(label: "Tip: prefer provider/model (e.g. openai-codex/gpt-5.2)", color: .orange)
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
@@ -387,22 +464,33 @@ struct ConfigSettings: View {
|
||||
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agent = parsed["agent"] as? [String: Any]
|
||||
let heartbeatMinutes = agent?["heartbeatMinutes"] as? Int
|
||||
let heartbeatBody = agent?["heartbeatBody"] as? String
|
||||
let agents = parsed["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
let heartbeat = defaults?["heartbeat"] as? [String: Any]
|
||||
let heartbeatEvery = heartbeat?["every"] as? String
|
||||
let heartbeatBody = heartbeat?["prompt"] as? String
|
||||
let browser = parsed["browser"] as? [String: Any]
|
||||
let talk = parsed["talk"] as? [String: Any]
|
||||
|
||||
let loadedModel = (agent?["model"] as? String) ?? ""
|
||||
let loadedModel: String = {
|
||||
if let raw = defaults?["model"] as? String { return raw }
|
||||
if let modelDict = defaults?["model"] as? [String: Any],
|
||||
let primary = modelDict["primary"] as? String { return primary }
|
||||
return ""
|
||||
}()
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
self.customModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
self.customModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatMinutes { self.heartbeatMinutes = heartbeatMinutes }
|
||||
if let heartbeatEvery {
|
||||
let digits = heartbeatEvery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.prefix { $0.isNumber }
|
||||
if let minutes = Int(digits) {
|
||||
self.heartbeatMinutes = minutes
|
||||
}
|
||||
}
|
||||
if let heartbeatBody, !heartbeatBody.isEmpty { self.heartbeatBody = heartbeatBody }
|
||||
|
||||
if let browser {
|
||||
@@ -446,7 +534,6 @@ struct ConfigSettings: View {
|
||||
defer { self.configSaving = false }
|
||||
|
||||
let configModel = self.configModel
|
||||
let customModel = self.customModel
|
||||
let heartbeatMinutes = self.heartbeatMinutes
|
||||
let heartbeatBody = self.heartbeatBody
|
||||
let browserEnabled = self.browserEnabled
|
||||
@@ -459,7 +546,6 @@ struct ConfigSettings: View {
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
@@ -480,25 +566,48 @@ struct ConfigSettings: View {
|
||||
@MainActor
|
||||
private static func buildAndSaveConfig(_ draft: ConfigDraft) async -> String? {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
var talk = root["talk"] as? [String: Any] ?? [:]
|
||||
|
||||
let chosenModel = (draft.configModel == "__custom__" ? draft.customModel : draft.configModel)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chosenModel = draft.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedModel = chosenModel
|
||||
if !trimmedModel.isEmpty { agent["model"] = trimmedModel }
|
||||
if !trimmedModel.isEmpty {
|
||||
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||
model["primary"] = trimmedModel
|
||||
defaults["model"] = model
|
||||
|
||||
var models = defaults["models"] as? [String: Any] ?? [:]
|
||||
if models[trimmedModel] == nil {
|
||||
models[trimmedModel] = [:]
|
||||
}
|
||||
defaults["models"] = models
|
||||
}
|
||||
|
||||
if let heartbeatMinutes = draft.heartbeatMinutes {
|
||||
agent["heartbeatMinutes"] = heartbeatMinutes
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["every"] = "\(heartbeatMinutes)m"
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
let trimmedBody = draft.heartbeatBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedBody.isEmpty {
|
||||
agent["heartbeatBody"] = trimmedBody
|
||||
var heartbeat = defaults["heartbeat"] as? [String: Any] ?? [:]
|
||||
heartbeat["prompt"] = trimmedBody
|
||||
defaults["heartbeat"] = heartbeat
|
||||
}
|
||||
|
||||
root["agent"] = agent
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
|
||||
browser["enabled"] = draft.browserEnabled
|
||||
let trimmedUrl = draft.browserControlUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -641,23 +750,11 @@ struct ConfigSettings: View {
|
||||
timeoutMs: 15000)
|
||||
self.models = res.models
|
||||
self.modelsSourceLabel = "gateway"
|
||||
if !self.configModel.isEmpty,
|
||||
!res.models.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
do {
|
||||
let loaded = try await ModelCatalogLoader.load(from: self.modelCatalogPath)
|
||||
self.models = loaded
|
||||
self.modelsSourceLabel = "local fallback"
|
||||
if !self.configModel.isEmpty,
|
||||
!loaded.contains(where: { $0.id == self.configModel })
|
||||
{
|
||||
self.customModel = self.configModel
|
||||
self.configModel = "__custom__"
|
||||
}
|
||||
} catch {
|
||||
self.modelError = error.localizedDescription
|
||||
self.models = []
|
||||
@@ -670,11 +767,129 @@ struct ConfigSettings: View {
|
||||
let models: [ModelChoice]
|
||||
}
|
||||
|
||||
private var modelSearchMatches: [ModelChoice] {
|
||||
let raw = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !raw.isEmpty else { return self.models }
|
||||
let tokens = raw
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { token in
|
||||
token.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return self.models }
|
||||
return self.models.filter { choice in
|
||||
let haystack = [
|
||||
choice.id,
|
||||
choice.name,
|
||||
choice.provider,
|
||||
self.modelRef(for: choice),
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
return tokens.allSatisfy { haystack.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelChoice: ModelChoice? {
|
||||
guard !self.configModel.isEmpty else { return nil }
|
||||
return self.models.first(where: { self.matchesConfigModel($0) })
|
||||
}
|
||||
|
||||
private var modelPickerLabel: String {
|
||||
if let choice = self.selectedModelChoice {
|
||||
return "\(choice.name) — \(choice.provider.uppercased())"
|
||||
}
|
||||
if !self.configModel.isEmpty { return self.configModel }
|
||||
return "Select model"
|
||||
}
|
||||
|
||||
private var modelPickerLabelIsPlaceholder: Bool {
|
||||
self.configModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private var manualEntryCandidate: String? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
guard !self.isKnownModelRef(cleaned) else { return nil }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
private func isKnownModelRef(_ value: String) -> Bool {
|
||||
let needle = value.lowercased()
|
||||
return self.models.contains { choice in
|
||||
choice.id.lowercased() == needle
|
||||
|| self.modelRef(for: choice).lowercased() == needle
|
||||
}
|
||||
}
|
||||
|
||||
private func modelRef(for choice: ModelChoice) -> String {
|
||||
let id = choice.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let provider = choice.provider.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !provider.isEmpty else { return id }
|
||||
let normalizedProvider = provider.lowercased()
|
||||
if id.lowercased().hasPrefix("\(normalizedProvider)/") {
|
||||
return id
|
||||
}
|
||||
return "\(normalizedProvider)/\(id)"
|
||||
}
|
||||
|
||||
private func matchesConfigModel(_ choice: ModelChoice) -> Bool {
|
||||
let configured = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !configured.isEmpty else { return false }
|
||||
if configured.caseInsensitiveCompare(choice.id) == .orderedSame { return true }
|
||||
let ref = self.modelRef(for: choice)
|
||||
return configured.caseInsensitiveCompare(ref) == .orderedSame
|
||||
}
|
||||
|
||||
private func exactMatchForQuery() -> ModelChoice? {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%")).lowercased()
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return self.models.first(where: { choice in
|
||||
let id = choice.id.lowercased()
|
||||
if id == cleaned { return true }
|
||||
return self.modelRef(for: choice).lowercased() == cleaned
|
||||
})
|
||||
}
|
||||
|
||||
private var shouldShowProviderHint: Bool {
|
||||
let trimmed = self.modelSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let cleaned = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "%"))
|
||||
return !cleaned.contains("/")
|
||||
}
|
||||
|
||||
private var shouldShowProviderHintForSelection: Bool {
|
||||
let trimmed = self.configModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return !trimmed.contains("/")
|
||||
}
|
||||
|
||||
private func selectModel(_ choice: ModelChoice) {
|
||||
self.configModel = self.modelRef(for: choice)
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private func selectManualModel(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let slash = trimmed.firstIndex(of: "/") {
|
||||
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let model = trimmed[trimmed.index(after: slash)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.configModel = provider.isEmpty ? String(model) : "\(provider)/\(model)"
|
||||
} else {
|
||||
self.configModel = trimmed
|
||||
}
|
||||
self.autosaveConfig()
|
||||
self.isModelPickerOpen = false
|
||||
}
|
||||
|
||||
private var selectedContextLabel: String? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard
|
||||
!chosenId.isEmpty,
|
||||
let choice = self.models.first(where: { $0.id == chosenId }),
|
||||
let choice = self.selectedModelChoice,
|
||||
let context = choice.contextWindow
|
||||
else {
|
||||
return nil
|
||||
@@ -685,8 +900,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
|
||||
guard let choice = self.selectedModelChoice else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ final class ControlChannel {
|
||||
self.logger.info(
|
||||
"control channel configure mode=remote " +
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.configure()
|
||||
} catch {
|
||||
@@ -182,7 +183,7 @@ final class ControlChannel {
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
return
|
||||
"Gateway rejected token; set CLAWDBOT_GATEWAY_TOKEN in the mac app environment " +
|
||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"or clear it on the gateway. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
enum DebugActions {
|
||||
private static let verboseDefaultsKey = "clawdbot.debug.verboseMain"
|
||||
private static let sessionMenuLimit = 12
|
||||
private static let onboardingSeenKey = "clawdbot.onboardingSeen"
|
||||
|
||||
@MainActor
|
||||
static func openAgentEventsWindow() {
|
||||
@@ -183,6 +184,14 @@ enum DebugActions {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartOnboarding() {
|
||||
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
|
||||
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = false
|
||||
OnboardingController.shared.restart()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func resolveSessionStorePath() -> String {
|
||||
let defaultPath = SessionLoader.defaultStorePath
|
||||
|
||||
@@ -28,7 +28,6 @@ struct DebugSettings: View {
|
||||
@State private var tunnelResetInFlight = false
|
||||
@State private var tunnelResetStatus: String?
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
|
||||
@@ -145,16 +144,6 @@ struct DebugSettings: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$attachExistingGatewayOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"When enabled in local mode, the mac app will only connect " +
|
||||
"to an already-running gateway " +
|
||||
"and will not start one itself.")
|
||||
}
|
||||
}
|
||||
|
||||
let key = DeepLinkHandler.currentKey()
|
||||
@@ -497,6 +486,7 @@ struct DebugSettings: View {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||
Button("Reveal app in Finder") { self.revealApp() }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -782,7 +772,7 @@ struct DebugSettings: View {
|
||||
}
|
||||
|
||||
private var canRestartGateway: Bool {
|
||||
self.state.connectionMode == .local && !self.attachExistingGatewayOnly
|
||||
self.state.connectionMode == .local && !self.state.attachExistingGatewayOnly
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
|
||||
@@ -3,6 +3,7 @@ import OSLog
|
||||
|
||||
enum GatewayEndpointState: Sendable, Equatable {
|
||||
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
|
||||
case connecting(mode: AppState.ConnectionMode, detail: String)
|
||||
case unavailable(mode: AppState.ConnectionMode, reason: String)
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ enum GatewayEndpointState: Sendable, Equatable {
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -26,7 +28,13 @@ actor GatewayEndpointStore {
|
||||
|
||||
static let live = Deps(
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: { ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
@@ -83,11 +91,46 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
private var state: GatewayEndpointState
|
||||
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
|
||||
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
|
||||
|
||||
init(deps: Deps = .live) {
|
||||
self.deps = deps
|
||||
@@ -115,7 +158,8 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
|
||||
Task { await self.setMode(.remote) }
|
||||
case .unconfigured:
|
||||
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
|
||||
}
|
||||
@@ -144,6 +188,7 @@ actor GatewayEndpointStore {
|
||||
let password = self.deps.password()
|
||||
switch mode {
|
||||
case .local:
|
||||
self.cancelRemoteEnsure()
|
||||
let port = self.deps.localPort()
|
||||
let host = await self.deps.localHost()
|
||||
self.setState(.ready(
|
||||
@@ -154,15 +199,18 @@ actor GatewayEndpointStore {
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
|
||||
return
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
case .unconfigured:
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
}
|
||||
@@ -176,8 +224,10 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let port = try await self.deps.ensureRemoteTunnel()
|
||||
await self.setMode(.remote)
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
@@ -186,6 +236,11 @@ actor GatewayEndpointStore {
|
||||
switch self.state {
|
||||
case let .ready(_, url, token, password):
|
||||
return (url, token, password)
|
||||
case let .connecting(mode, _):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
}
|
||||
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
case let .unavailable(mode, reason):
|
||||
guard mode == .remote else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
|
||||
@@ -193,21 +248,73 @@ actor GatewayEndpointStore {
|
||||
|
||||
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
|
||||
// recreate it on demand so callers can recover without a manual reconnect.
|
||||
do {
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
let forwarded = try await self.deps.ensureRemoteTunnel()
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch {
|
||||
let msg = "\(reason) (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
self.logger.info(
|
||||
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
|
||||
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelRemoteEnsure() {
|
||||
self.remoteEnsure?.task.cancel()
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
|
||||
private func kickRemoteEnsureIfNeeded(detail: String) {
|
||||
if self.remoteEnsure != nil {
|
||||
self.setState(.connecting(mode: .remote, detail: detail))
|
||||
return
|
||||
}
|
||||
|
||||
let deps = self.deps
|
||||
let token = UUID()
|
||||
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
|
||||
self.remoteEnsure = (token: token, task: task)
|
||||
self.setState(.connecting(mode: .remote, detail: detail))
|
||||
}
|
||||
|
||||
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
}
|
||||
|
||||
do {
|
||||
let forwarded = try await ensure.task.value
|
||||
let stillRemote = await self.deps.mode() == .remote
|
||||
guard stillRemote else {
|
||||
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch let err as CancellationError {
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
throw err
|
||||
} catch {
|
||||
if self.remoteEnsure?.token == ensure.token {
|
||||
self.remoteEnsure = nil
|
||||
}
|
||||
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
|
||||
self.setState(.unavailable(mode: .remote, reason: msg))
|
||||
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +335,11 @@ actor GatewayEndpointStore {
|
||||
self.logger
|
||||
.debug(
|
||||
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
|
||||
case let .connecting(mode, detail):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
.debug(
|
||||
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
|
||||
case let .unavailable(mode, reason):
|
||||
let modeDesc = String(describing: mode)
|
||||
self.logger
|
||||
|
||||
@@ -64,6 +64,7 @@ struct GatewayCommandResolution {
|
||||
enum GatewayEnvironment {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let bundledGatewayLabel = "Bundled gateway"
|
||||
|
||||
static func bundledGatewayExecutable() -> String? {
|
||||
guard let res = Bundle.main.resourceURL else { return nil }
|
||||
@@ -71,6 +72,12 @@ enum GatewayEnvironment {
|
||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
static func bundledNodeExecutable() -> String? {
|
||||
guard let res = Bundle.main.resourceURL else { return nil }
|
||||
let path = res.appendingPathComponent("Relay/node").path
|
||||
return FileManager.default.isExecutableFile(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
static func gatewayPort() -> Int {
|
||||
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PORT"] {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -107,13 +114,15 @@ enum GatewayEnvironment {
|
||||
|
||||
if let bundled = self.bundledGatewayExecutable() {
|
||||
let installed = self.readGatewayVersion(binary: bundled)
|
||||
let bundledNode = self.bundledNodeExecutable()
|
||||
let bundledNodeVersion = bundledNode.flatMap { self.readNodeVersion(binary: $0) }
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let message =
|
||||
"Bundled gateway \(installed.description) is incompatible with app " +
|
||||
"\(expected.description); rebuild the app bundle."
|
||||
let message = self.bundledGatewayIncompatibleMessage(
|
||||
installed: installed,
|
||||
expected: expected)
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: nil,
|
||||
nodeVersion: bundledNodeVersion,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: message)
|
||||
@@ -121,10 +130,12 @@ enum GatewayEnvironment {
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: nil,
|
||||
nodeVersion: bundledNodeVersion,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Bundled gateway \(gatewayVersionText) (bun)")
|
||||
message: self.bundledGatewayStatusMessage(
|
||||
gatewayVersion: gatewayVersionText,
|
||||
nodeVersion: bundledNodeVersion))
|
||||
}
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
@@ -351,4 +362,41 @@ enum GatewayEnvironment {
|
||||
else { return nil }
|
||||
return Semver.parse(version)
|
||||
}
|
||||
|
||||
private static func readNodeVersion(binary: String) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: binary)
|
||||
process.arguments = ["--version"]
|
||||
process.environment = ["PATH": CommandResolver.preferredPaths().joined(separator: ":")]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^v", with: "", options: .regularExpression)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func bundledGatewayStatusMessage(
|
||||
gatewayVersion: String,
|
||||
nodeVersion: String?) -> String
|
||||
{
|
||||
"\(self.bundledGatewayLabel) \(gatewayVersion) (node \(nodeVersion ?? "unknown"))"
|
||||
}
|
||||
|
||||
private static func bundledGatewayIncompatibleMessage(
|
||||
installed: Semver,
|
||||
expected: Semver) -> String
|
||||
{
|
||||
"\(self.bundledGatewayLabel) \(installed.description) is incompatible with app " +
|
||||
"\(expected.description); rebuild the app bundle."
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ enum GatewayLaunchAgentManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
@@ -50,6 +51,10 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (attach-only or disable marker set)")
|
||||
return nil
|
||||
}
|
||||
if enabled {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
@@ -204,7 +209,20 @@ enum GatewayLaunchAgentManager {
|
||||
private static func preferredGatewayToken() -> String? {
|
||||
let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func preferredGatewayPassword() -> String? {
|
||||
@@ -288,6 +306,17 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
if UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) {
|
||||
return true
|
||||
}
|
||||
let marker = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||
return FileManager.default.fileExists(atPath: marker.path)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayLaunchAgentManager {
|
||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
||||
|
||||
@@ -239,7 +239,7 @@ final class GatewayProcessManager {
|
||||
let lower = message.lowercased()
|
||||
if self.isGatewayAuthFailure(error) {
|
||||
return """
|
||||
Gateway on port \(port) rejected auth. Set CLAWDBOT_GATEWAY_TOKEN in the app \
|
||||
Gateway on port \(port) rejected auth. Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) \
|
||||
to match the running gateway (or clear it on the gateway) and retry.
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
@@ -12,7 +13,8 @@ struct GeneralSettings: View {
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel()
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@@ -28,10 +30,22 @@ struct GeneralSettings: View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Text("Complete onboarding to finish setup")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.bottom, 2)
|
||||
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) {
|
||||
@@ -150,13 +164,18 @@ struct GeneralSettings: View {
|
||||
|
||||
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
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
if status == .authorizedAlways {
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||
return updated == .authorizedAlways
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
@@ -185,6 +204,11 @@ struct GeneralSettings: View {
|
||||
if !self.isNixMode {
|
||||
self.gatewayInstallerCard
|
||||
}
|
||||
SettingsToggleRow(
|
||||
title: "Attach only",
|
||||
subtitle: "Use this when the gateway runs externally; the mac app will only attach " +
|
||||
"to an already-running gateway and won't start one locally.",
|
||||
binding: self.$state.attachExistingGatewayOnly)
|
||||
TailscaleIntegrationSection(
|
||||
connectionMode: self.state.connectionMode,
|
||||
isPaused: self.state.isPaused)
|
||||
|
||||
@@ -283,6 +283,11 @@ struct MenuContent: View {
|
||||
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartApp()
|
||||
} label: {
|
||||
|
||||
@@ -111,6 +111,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
let isConnected = self.isControlChannelConnected
|
||||
let channelState = ControlChannel.shared.state
|
||||
|
||||
var cursor = insertIndex
|
||||
var headerView: NSView?
|
||||
@@ -133,7 +134,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : "Gateway disconnected")),
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -166,7 +167,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
headerItem.isEnabled = false
|
||||
let statusText = isConnected
|
||||
? (self.cachedErrorText ?? "Loading sessions…")
|
||||
: "Gateway disconnected"
|
||||
: self.controlChannelStatusText(for: channelState)
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: 0,
|
||||
@@ -218,6 +219,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if case .connecting = ControlChannel.shared.state {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
@@ -272,18 +281,36 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
|
||||
var cursor = cursor
|
||||
|
||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.tag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
headerItem.view = self.makeHostedView(
|
||||
rootView: AnyView(MenuUsageHeaderView(
|
||||
count: rows.count,
|
||||
statusText: errorText)),
|
||||
count: rows.count)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
text: errorText,
|
||||
symbolName: "exclamationmark.triangle",
|
||||
width: width,
|
||||
maxLines: 2),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||
@@ -292,6 +319,28 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return cursor
|
||||
}
|
||||
|
||||
if let selectedProvider = self.selectedUsageProviderId,
|
||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||
rows.count > 1
|
||||
{
|
||||
let others = rows.filter { $0.providerId.lowercased() != selectedProvider }
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = true
|
||||
if !others.isEmpty {
|
||||
item.submenu = self.buildUsageOverflowMenu(rows: others, width: width)
|
||||
}
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(UsageMenuLabelView(row: primary, width: width, showsChevron: !others.isEmpty)),
|
||||
width: width,
|
||||
highlighted: true)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
return cursor
|
||||
}
|
||||
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
@@ -307,11 +356,34 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return cursor
|
||||
}
|
||||
|
||||
private var selectedUsageProviderId: String? {
|
||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let slash = trimmed.firstIndex(of: "/") else { return nil }
|
||||
let provider = trimmed[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return provider.nonEmpty
|
||||
}
|
||||
|
||||
private var usageRows: [UsageRow] {
|
||||
guard let summary = self.cachedUsageSummary else { return [] }
|
||||
return summary.primaryRows()
|
||||
}
|
||||
|
||||
private func buildUsageOverflowMenu(rows: [UsageRow], width: CGFloat) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
for row in rows {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
item.view = self.makeHostedView(
|
||||
rootView: AnyView(UsageMenuLabelView(row: row, width: width)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
menu.addItem(item)
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
private var isControlChannelConnected: Bool {
|
||||
#if DEBUG
|
||||
if let override = self.testControlChannelConnected { return override }
|
||||
@@ -320,6 +392,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
return "Loading sessions…"
|
||||
case .connecting:
|
||||
return "Connecting…"
|
||||
case let .degraded(message):
|
||||
return message.nonEmpty ?? "Gateway disconnected"
|
||||
case .disconnected:
|
||||
return "Gateway disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -391,18 +476,31 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
Label(text, systemImage: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(minWidth: 300, alignment: .leading))
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 14, alignment: .leading)
|
||||
.padding(.top, 1)
|
||||
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(maxLines)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: max(1, width), alignment: .leading))
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
@@ -938,6 +1036,12 @@ extension MenuSessionsInjector {
|
||||
self.cacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
func setTestingUsageSummary(_ summary: GatewayUsageSummary?, errorText: String? = nil) {
|
||||
self.cachedUsageSummary = summary
|
||||
self.cachedUsageErrorText = errorText
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
func injectForTesting(into menu: NSMenu) {
|
||||
self.inject(into: menu)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct MenuUsageHeaderView: View {
|
||||
let count: Int
|
||||
let statusText: String?
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
@@ -20,14 +19,6 @@ struct MenuUsageHeaderView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor MacNodeBridgeSession {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
@@ -15,14 +16,20 @@ actor MacNodeBridgeSession {
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let clock = ContinuousClock()
|
||||
private var disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var queue: DispatchQueue?
|
||||
private var buffer = Data()
|
||||
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var lastPongAt: ContinuousClock.Instant?
|
||||
|
||||
private(set) var state: State = .idle
|
||||
|
||||
@@ -30,14 +37,22 @@ actor MacNodeBridgeSession {
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.disconnectHandler = onDisconnected
|
||||
self.state = .connecting
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||
self.connection = connection
|
||||
@@ -47,6 +62,10 @@ actor MacNodeBridgeSession {
|
||||
connection.start(queue: queue)
|
||||
|
||||
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleConnectionState(state) }
|
||||
}
|
||||
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
@@ -68,6 +87,7 @@ actor MacNodeBridgeSession {
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
self.logger.error("node bridge hello failed (unexpected response)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
@@ -77,53 +97,72 @@ actor MacNodeBridgeSession {
|
||||
if base.type == "hello-ok" {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.startPingLoop()
|
||||
await onConnected?(ok.serverName)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
} else {
|
||||
self.state = .failed(message: "Unexpected bridge response")
|
||||
self.logger.error("node bridge hello failed (unexpected frame)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
while true {
|
||||
guard let next = try await self.receiveLine() else { break }
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||
do {
|
||||
while true {
|
||||
guard let next = try await self.receiveLine() else { break }
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||
|
||||
switch nextBase.type {
|
||||
case "res":
|
||||
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
|
||||
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
switch nextBase.type {
|
||||
case "res":
|
||||
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
|
||||
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
|
||||
case "event":
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
|
||||
self.broadcastServerEvent(evt)
|
||||
|
||||
case "ping":
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
|
||||
case "pong":
|
||||
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
|
||||
self.notePong(pong)
|
||||
|
||||
case "invoke":
|
||||
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||
let taskID = UUID()
|
||||
let task = Task { [weak self] in
|
||||
let res = await onInvoke(req)
|
||||
guard let self else { return }
|
||||
await self.sendInvokeResponse(res, taskID: taskID)
|
||||
}
|
||||
self.invokeTasks[taskID] = task
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
case "event":
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
|
||||
self.broadcastServerEvent(evt)
|
||||
|
||||
case "ping":
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
|
||||
case "invoke":
|
||||
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||
let res = await onInvoke(req)
|
||||
try await self.send(res)
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await self.disconnect()
|
||||
await self.handleDisconnect(reason: "connection closed")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
|
||||
await self.handleDisconnect(reason: "receive failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async throws {
|
||||
@@ -182,6 +221,12 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
self.pingTask?.cancel()
|
||||
self.pingTask = nil
|
||||
self.lastPongAt = nil
|
||||
self.disconnectHandler = nil
|
||||
self.cancelInvokeTasks()
|
||||
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.queue = nil
|
||||
@@ -239,12 +284,17 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
guard let connection = self.connection else {
|
||||
throw NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.connection?.send(content: line, completion: .contentProcessed { err in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
@@ -280,6 +330,106 @@ actor MacNodeBridgeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func startPingLoop() {
|
||||
self.pingTask?.cancel()
|
||||
self.lastPongAt = self.clock.now
|
||||
self.logger.debug("node bridge ping loop started")
|
||||
self.pingTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.runPingLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func runPingLoop() async {
|
||||
let interval: Duration = .seconds(15)
|
||||
let timeout: Duration = .seconds(45)
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: interval)
|
||||
|
||||
guard self.connection != nil else { return }
|
||||
|
||||
if let last = self.lastPongAt {
|
||||
let now = self.clock.now
|
||||
if now > last.advanced(by: timeout) {
|
||||
let age = last.duration(to: now)
|
||||
let ageDescription = String(describing: age)
|
||||
let message =
|
||||
"Node bridge heartbeat timed out; disconnecting " +
|
||||
"(age: \(ageDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "ping timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID().uuidString
|
||||
do {
|
||||
try await self.send(BridgePing(type: "ping", id: id))
|
||||
} catch {
|
||||
let errorDescription = String(describing: error)
|
||||
let message =
|
||||
"Node bridge ping send failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "ping send failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notePong(_ pong: BridgePong) {
|
||||
_ = pong
|
||||
self.lastPongAt = self.clock.now
|
||||
}
|
||||
|
||||
private func handleConnectionState(_ state: NWConnection.State) async {
|
||||
switch state {
|
||||
case let .failed(error):
|
||||
let errorDescription = String(describing: error)
|
||||
let message =
|
||||
"Node bridge connection failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "connection failed")
|
||||
case .cancelled:
|
||||
self.logger.warning("Node bridge connection cancelled; disconnecting.")
|
||||
await self.handleDisconnect(reason: "connection cancelled")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDisconnect(reason: String) async {
|
||||
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
|
||||
if let handler = self.disconnectHandler {
|
||||
await handler(reason)
|
||||
}
|
||||
await self.disconnect()
|
||||
}
|
||||
|
||||
private func logInvokeSendFailure(_ error: Error) {
|
||||
self.logger.error(
|
||||
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
|
||||
defer { self.invokeTasks[taskID] = nil }
|
||||
if Task.isCancelled { return }
|
||||
do {
|
||||
try await self.send(response)
|
||||
} catch {
|
||||
await self.logInvokeSendFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelInvokeTasks() {
|
||||
for task in self.invokeTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
self.invokeTasks.removeAll()
|
||||
}
|
||||
|
||||
private static func makeStateStream(
|
||||
for connection: NWConnection) -> AsyncStream<NWConnection.State>
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
@@ -61,12 +62,17 @@ final class MacNodeModeCoordinator {
|
||||
retryDelay = 1_000_000_000
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
self.logger.info(
|
||||
"mac node bridge connecting endpoint=\(endpoint, privacy: .public)")
|
||||
try await self.session.connect(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onConnected: { [weak self] serverName in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
},
|
||||
onDisconnected: { reason in
|
||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
return BridgeInvokeResponse(
|
||||
@@ -80,7 +86,8 @@ final class MacNodeModeCoordinator {
|
||||
if await self.tryPair(endpoint: endpoint, error: error) {
|
||||
continue
|
||||
}
|
||||
self.logger.error("mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.logger.error(
|
||||
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
}
|
||||
@@ -285,15 +292,33 @@ final class MacNodeModeCoordinator {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
do {
|
||||
if self.tunnel == nil || self.tunnel?.process.isRunning == false {
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
if let tunnel = self.tunnel,
|
||||
tunnel.process.isRunning,
|
||||
let localPort = tunnel.localPort
|
||||
{
|
||||
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0)
|
||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||
self.logger.info(
|
||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
allowRemoteUrlOverride: false)
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
{
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
return .hostPort(host: "127.0.0.1", port: port)
|
||||
}
|
||||
} catch {
|
||||
@@ -311,6 +336,21 @@ final class MacNodeModeCoordinator {
|
||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func handleBridgeDisconnect(reason: String) async {
|
||||
guard reason.localizedCaseInsensitiveContains("ping") else { return }
|
||||
let coordinator = MacNodeModeCoordinator.shared
|
||||
coordinator.logger.error(
|
||||
"mac node bridge disconnected (\(reason, privacy: .public)); resetting tunnel")
|
||||
coordinator.tunnel?.terminate()
|
||||
coordinator.tunnel = nil
|
||||
}
|
||||
|
||||
private func bridgeTunnelHealthy(localPort: UInt16, timeoutSeconds: Double) async -> Bool {
|
||||
guard let port = NWEndpoint.Port(rawValue: localPort) else { return false }
|
||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> NWEndpoint? {
|
||||
final class DiscoveryState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
|
||||
@@ -222,7 +222,15 @@ actor MacNodeRuntime {
|
||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||
let services = await self.mainActorServices()
|
||||
let status = await services.locationAuthorizationStatus()
|
||||
if status != .authorizedAlways {
|
||||
let hasPermission = switch mode {
|
||||
case .always:
|
||||
status == .authorizedAlways
|
||||
case .whileUsing:
|
||||
status == .authorizedAlways
|
||||
case .off:
|
||||
false
|
||||
}
|
||||
if !hasPermission {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
@@ -533,7 +534,7 @@ final class NodePairingApprovalPrompter {
|
||||
return SSHTarget(host: host, port: port)
|
||||
}
|
||||
|
||||
let model = GatewayDiscoveryModel()
|
||||
let model = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
model.start()
|
||||
defer { model.stop() }
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import Combine
|
||||
import Observation
|
||||
@@ -31,7 +32,7 @@ final class OnboardingController {
|
||||
let hosting = NSHostingController(rootView: OnboardingView())
|
||||
let window = NSWindow(contentViewController: hosting)
|
||||
window.title = UIStrings.welcomeTitle
|
||||
window.setContentSize(NSSize(width: 630, height: 684))
|
||||
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
|
||||
window.styleMask = [.titled, .closable, .fullSizeContentView]
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
@@ -47,6 +48,11 @@ final class OnboardingController {
|
||||
self.window?.close()
|
||||
self.window = nil
|
||||
}
|
||||
|
||||
func restart() {
|
||||
self.close()
|
||||
self.show()
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@@ -92,7 +98,10 @@ struct OnboardingView: View {
|
||||
@Bindable var state: AppState
|
||||
var permissionMonitor: PermissionMonitor
|
||||
|
||||
let pageWidth: CGFloat = 630
|
||||
static let windowWidth: CGFloat = 630
|
||||
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
|
||||
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let anthropicAuthPageIndex = 2
|
||||
@@ -156,7 +165,9 @@ struct OnboardingView: View {
|
||||
init(
|
||||
state: AppState = AppStateStore.shared,
|
||||
permissionMonitor: PermissionMonitor = .shared,
|
||||
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel())
|
||||
discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName,
|
||||
filterLocalGateways: false))
|
||||
{
|
||||
self.state = state
|
||||
self.permissionMonitor = permissionMonitor
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ extension OnboardingView {
|
||||
Spacer(minLength: 0)
|
||||
self.navigationBar
|
||||
}
|
||||
.frame(width: self.pageWidth, height: 684)
|
||||
.frame(width: self.pageWidth, height: Self.windowHeight)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.currentPage = 0
|
||||
@@ -173,6 +173,22 @@ extension OnboardingView {
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
|
||||
}
|
||||
|
||||
func onboardingGlassCard(
|
||||
spacing: CGFloat = 12,
|
||||
padding: CGFloat = 16,
|
||||
@ViewBuilder _ content: () -> some View) -> some View
|
||||
{
|
||||
let shape = RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
return VStack(alignment: .leading, spacing: spacing) {
|
||||
content()
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.clear)
|
||||
.clipShape(shape)
|
||||
.overlay(shape.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
|
||||
}
|
||||
|
||||
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
|
||||
@@ -33,7 +33,7 @@ extension OnboardingView {
|
||||
if shouldMonitor, !self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 550_000_000)
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.gatewayDiscovery.start()
|
||||
await self.refreshLocalGatewayProbe()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import ClawdbotChatUI
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import SwiftUI
|
||||
|
||||
@@ -115,6 +116,11 @@ extension OnboardingView {
|
||||
.foregroundStyle(.secondary)
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Refresh") {
|
||||
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.help("Retry Tailscale discovery (DNS-SD).")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -607,7 +613,7 @@ extension OnboardingView {
|
||||
let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url))
|
||||
if saved {
|
||||
self.workspaceStatus =
|
||||
"Saved to ~/.clawdbot/clawdbot.json (agent.workspace)"
|
||||
"Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,7 +654,7 @@ extension OnboardingView {
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(padding: 8) {
|
||||
self.onboardingGlassCard(padding: 8) {
|
||||
ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
@@ -5,7 +6,7 @@ import SwiftUI
|
||||
extension OnboardingView {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Searching..."
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Test Bridge",
|
||||
|
||||
@@ -69,8 +69,9 @@ extension OnboardingView {
|
||||
|
||||
private func loadAgentWorkspace() async -> String? {
|
||||
let root = await ConfigStore.load()
|
||||
let agent = root["agent"] as? [String: Any]
|
||||
return agent?["workspace"] as? String
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
return defaults?["workspace"] as? String
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -86,17 +87,23 @@ extension OnboardingView {
|
||||
@MainActor
|
||||
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var agent = root["agent"] as? [String: Any] ?? [:]
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
agent.removeValue(forKey: "workspace")
|
||||
defaults.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
agent["workspace"] = trimmed
|
||||
defaults["workspace"] = trimmed
|
||||
}
|
||||
if agent.isEmpty {
|
||||
root.removeValue(forKey: "agent")
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
root["agent"] = agent
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
|
||||
@@ -10,6 +10,18 @@ import Speech
|
||||
import UserNotifications
|
||||
|
||||
enum PermissionManager {
|
||||
static func isLocationAuthorized(status: CLAuthorizationStatus, requireAlways: Bool) -> Bool {
|
||||
if requireAlways { return status == .authorizedAlways }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
case .authorized: // deprecated, but still shows up on some macOS versions
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func ensure(_ caps: [Capability], interactive: Bool) async -> [Capability: Bool] {
|
||||
var results: [Capability: Bool] = [:]
|
||||
for cap in caps {
|
||||
@@ -138,18 +150,23 @@ enum PermissionManager {
|
||||
}
|
||||
|
||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
if interactive {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
}
|
||||
return false
|
||||
}
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
switch status {
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
case .authorizedAlways:
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||
return updated == .authorizedAlways
|
||||
return self.isLocationAuthorized(status: updated, requireAlways: false)
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
LocationPermissionHelper.openSettings()
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
@@ -202,8 +219,8 @@ enum PermissionManager {
|
||||
|
||||
case .location:
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways
|
||||
results[cap] = status == .authorizedAlways
|
||||
results[cap] = CLLocationManager.locationServicesEnabled()
|
||||
&& self.isLocationAuthorized(status: status, requireAlways: false)
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -275,6 +292,7 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||
static let shared = LocationPermissionRequester()
|
||||
private let manager = CLLocationManager()
|
||||
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -282,23 +300,71 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
|
||||
func request(always: Bool) async -> CLAuthorizationStatus {
|
||||
if always {
|
||||
self.manager.requestAlwaysAuthorization()
|
||||
} else {
|
||||
self.manager.requestWhenInUseAuthorization()
|
||||
let current = self.manager.authorizationStatus
|
||||
if PermissionManager.isLocationAuthorized(status: current, requireAlways: always) {
|
||||
return current
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
self.continuation = cont
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.continuation != nil else { return }
|
||||
LocationPermissionHelper.openSettings()
|
||||
self.finish(status: self.manager.authorizationStatus)
|
||||
}
|
||||
}
|
||||
if always {
|
||||
self.manager.requestAlwaysAuthorization()
|
||||
} else {
|
||||
self.manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
// On macOS, requesting an actual fix makes the prompt more reliable.
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(status: CLAuthorizationStatus) {
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
guard let cont = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
|
||||
// nonisolated for Swift 6 strict concurrency compatibility
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
guard let cont = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
cont.resume(returning: status)
|
||||
self.finish(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy callback (still used on some macOS versions / configurations).
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
Task { @MainActor in
|
||||
self.finish(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if status == .denied || status == .restricted {
|
||||
LocationPermissionHelper.openSettings()
|
||||
}
|
||||
self.finish(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.finish(status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct PermissionsSettings: View {
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
Button("Show onboarding") { self.showOnboarding() }
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ final class RemotePortTunnel {
|
||||
static func create(
|
||||
remotePort: Int,
|
||||
preferredLocalPort: UInt16? = nil,
|
||||
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
|
||||
allowRemoteUrlOverride: Bool = true,
|
||||
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
|
||||
{
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
@@ -51,7 +52,9 @@ final class RemotePortTunnel {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
|
||||
}
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
let localPort = try await Self.findPort(
|
||||
preferred: preferredLocalPort,
|
||||
allowRandom: allowRandomLocalPort)
|
||||
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remotePortOverride =
|
||||
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
|
||||
@@ -172,8 +175,16 @@ final class RemotePortTunnel {
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
|
||||
if let preferred, self.portIsFree(preferred) { return preferred }
|
||||
if let preferred, !allowRandom {
|
||||
throw NSError(
|
||||
domain: "RemotePortTunnel",
|
||||
code: 5,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable",
|
||||
])
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility)
|
||||
|
||||
@@ -7,8 +7,15 @@ actor RemoteTunnelManager {
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel")
|
||||
private var controlTunnel: RemotePortTunnel?
|
||||
private var restartInFlight = false
|
||||
private var lastRestartAt: Date?
|
||||
private let restartBackoffSeconds: TimeInterval = 2.0
|
||||
|
||||
func controlTunnelPortIfRunning() async -> UInt16? {
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skipping reuse check")
|
||||
return nil
|
||||
}
|
||||
if let tunnel = self.controlTunnel,
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
@@ -18,6 +25,7 @@ actor RemoteTunnelManager {
|
||||
return local
|
||||
}
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
@@ -34,6 +42,11 @@ actor RemoteTunnelManager {
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||
return nil
|
||||
}
|
||||
await self.beginRestart()
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
}
|
||||
return nil
|
||||
@@ -56,12 +69,15 @@ actor RemoteTunnelManager {
|
||||
"identitySet=\(identitySet, privacy: .public)")
|
||||
|
||||
if let local = await self.controlTunnelPortIfRunning() { return local }
|
||||
await self.waitForRestartBackoffIfNeeded()
|
||||
|
||||
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||
let tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: GatewayEnvironment.gatewayPort(),
|
||||
preferredLocalPort: desiredPort)
|
||||
preferredLocalPort: desiredPort,
|
||||
allowRandomLocalPort: false)
|
||||
self.controlTunnel = tunnel
|
||||
self.endRestart()
|
||||
let resolvedPort = tunnel.localPort ?? desiredPort
|
||||
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
|
||||
return tunnel.localPort ?? desiredPort
|
||||
@@ -83,6 +99,35 @@ actor RemoteTunnelManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private func beginRestart() async {
|
||||
guard !self.restartInFlight else { return }
|
||||
self.restartInFlight = true
|
||||
self.lastRestartAt = Date()
|
||||
self.logger.info("control tunnel restart started")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
|
||||
await self.endRestart()
|
||||
}
|
||||
}
|
||||
|
||||
private func endRestart() {
|
||||
if self.restartInFlight {
|
||||
self.restartInFlight = false
|
||||
self.logger.info("control tunnel restart finished")
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForRestartBackoffIfNeeded() async {
|
||||
guard let last = self.lastRestartAt else { return }
|
||||
let elapsed = Date().timeIntervalSince(last)
|
||||
let remaining = self.restartBackoffSeconds - elapsed
|
||||
guard remaining > 0 else { return }
|
||||
self.logger.info(
|
||||
"control tunnel restart backoff \(remaining, privacy: .public)s")
|
||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
}
|
||||
|
||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||
let pid = desc.pid
|
||||
self.logger.error(
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.8</string>
|
||||
<string>2026.1.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260108</string>
|
||||
<string>20260109</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -47,10 +47,14 @@
|
||||
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
|
||||
@@ -58,7 +58,7 @@ struct SettingsRootView: View {
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
refresh: self.refreshPerms,
|
||||
showOnboarding: { OnboardingController.shared.show() })
|
||||
showOnboarding: { DebugActions.restartOnboarding() })
|
||||
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
||||
.tag(SettingsTab.permissions)
|
||||
|
||||
|
||||
@@ -50,10 +50,13 @@ enum ShellExecutor {
|
||||
errorMessage: "failed to start: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let outTask = Task { stdoutPipe.fileHandleForReading.readToEndSafely() }
|
||||
let errTask = Task { stderrPipe.fileHandleForReading.readToEndSafely() }
|
||||
|
||||
let waitTask = Task { () -> ShellResult in
|
||||
process.waitUntilExit()
|
||||
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
|
||||
let err = stderrPipe.fileHandleForReading.readToEndSafely()
|
||||
let out = await outTask.value
|
||||
let err = await errTask.value
|
||||
let status = Int(process.terminationStatus)
|
||||
return ShellResult(
|
||||
stdout: String(bytes: out, encoding: .utf8) ?? "",
|
||||
|
||||
@@ -21,6 +21,7 @@ struct GatewayUsageSummary: Codable {
|
||||
|
||||
struct UsageRow: Identifiable {
|
||||
let id: String
|
||||
let providerId: String
|
||||
let displayName: String
|
||||
let plan: String?
|
||||
let windowLabel: String?
|
||||
@@ -28,6 +29,11 @@ struct UsageRow: Identifiable {
|
||||
let resetAt: Date?
|
||||
let error: String?
|
||||
|
||||
var hasError: Bool {
|
||||
if let error, !error.isEmpty { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var titleText: String {
|
||||
if let plan, !plan.isEmpty { return "\(self.displayName) (\(plan))" }
|
||||
return self.displayName
|
||||
@@ -40,7 +46,6 @@ struct UsageRow: Identifiable {
|
||||
}
|
||||
|
||||
func detailText(now: Date = .init()) -> String {
|
||||
if let error, !error.isEmpty { return error }
|
||||
guard let remaining = self.remainingPercent else { return "No data" }
|
||||
var parts = ["\(remaining)% left"]
|
||||
if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) }
|
||||
@@ -73,6 +78,7 @@ extension GatewayUsageSummary {
|
||||
if let error = provider.error, provider.windows.isEmpty {
|
||||
return UsageRow(
|
||||
id: provider.provider,
|
||||
providerId: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: nil,
|
||||
@@ -87,6 +93,7 @@ extension GatewayUsageSummary {
|
||||
|
||||
return UsageRow(
|
||||
id: "\(provider.provider)-\(window.label)",
|
||||
providerId: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: window.label,
|
||||
|
||||
@@ -3,12 +3,19 @@ import SwiftUI
|
||||
struct UsageMenuLabelView: View {
|
||||
let row: UsageRow
|
||||
let width: CGFloat
|
||||
var showsChevron: Bool = false
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
private let paddingLeading: CGFloat = 22
|
||||
private let paddingTrailing: CGFloat = 14
|
||||
private let barHeight: CGFloat = 6
|
||||
|
||||
private var primaryTextColor: Color { .primary }
|
||||
private var secondaryTextColor: Color { .secondary }
|
||||
private var primaryTextColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryTextColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -30,12 +37,31 @@ struct UsageMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
if !self.row.hasError {
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
|
||||
if self.showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.row.error?.nonEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
|
||||
@@ -219,6 +219,7 @@ final class WorkActivityStore {
|
||||
if let detail = display.detailLine, !detail.isEmpty {
|
||||
return "\(display.label): \(detail)"
|
||||
}
|
||||
|
||||
return display.label
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum BridgeEndpointID {
|
||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
public enum BridgeEndpointID {
|
||||
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||
switch endpoint {
|
||||
case let .service(name, type, domain, _):
|
||||
// Keep stable across encoded/decoded differences (e.g. \032 for spaces).
|
||||
@@ -14,7 +14,7 @@ enum BridgeEndpointID {
|
||||
}
|
||||
}
|
||||
|
||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
public static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||
BonjourEscapes.decode(String(describing: endpoint))
|
||||
}
|
||||
|
||||
@@ -6,43 +6,79 @@ import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayDiscoveryModel {
|
||||
struct LocalIdentity: Equatable {
|
||||
var hostTokens: Set<String>
|
||||
var displayTokens: Set<String>
|
||||
public final class GatewayDiscoveryModel {
|
||||
public struct LocalIdentity: Equatable, Sendable {
|
||||
public var hostTokens: Set<String>
|
||||
public var displayTokens: Set<String>
|
||||
|
||||
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
||||
self.hostTokens = hostTokens
|
||||
self.displayTokens = displayTokens
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredGateway: Identifiable, Equatable {
|
||||
var id: String { self.stableID }
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||
public var id: String { self.stableID }
|
||||
public var displayName: String
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var cliPath: String?
|
||||
public var stableID: String
|
||||
public var debugID: String
|
||||
public var isLocal: Bool
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
lanHost: String? = nil,
|
||||
tailnetDns: String? = nil,
|
||||
sshPort: Int,
|
||||
gatewayPort: Int? = nil,
|
||||
cliPath: String? = nil,
|
||||
stableID: String,
|
||||
debugID: String,
|
||||
isLocal: Bool)
|
||||
{
|
||||
self.displayName = displayName
|
||||
self.lanHost = lanHost
|
||||
self.tailnetDns = tailnetDns
|
||||
self.sshPort = sshPort
|
||||
self.gatewayPort = gatewayPort
|
||||
self.cliPath = cliPath
|
||||
self.stableID = stableID
|
||||
self.debugID = debugID
|
||||
self.isLocal = isLocal
|
||||
}
|
||||
}
|
||||
|
||||
var gateways: [DiscoveredGateway] = []
|
||||
var statusText: String = "Idle"
|
||||
public var gateways: [DiscoveredGateway] = []
|
||||
public var statusText: String = "Idle"
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var localIdentity: LocalIdentity
|
||||
private let localDisplayName: String?
|
||||
private let filterLocalGateways: Bool
|
||||
private var resolvedTXTByID: [String: [String: String]] = [:]
|
||||
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:]
|
||||
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-discovery")
|
||||
|
||||
init() {
|
||||
self.localIdentity = Self.buildLocalIdentityFast()
|
||||
public init(
|
||||
localDisplayName: String? = nil,
|
||||
filterLocalGateways: Bool = true)
|
||||
{
|
||||
self.localDisplayName = localDisplayName
|
||||
self.filterLocalGateways = filterLocalGateways
|
||||
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
||||
self.refreshLocalIdentity()
|
||||
}
|
||||
|
||||
func start() {
|
||||
public func start() {
|
||||
if !self.browsers.isEmpty { return }
|
||||
|
||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||
@@ -72,9 +108,24 @@ final class GatewayDiscoveryModel {
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.gateway-discovery.\(domain)"))
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
}
|
||||
@@ -85,15 +136,52 @@ final class GatewayDiscoveryModel {
|
||||
self.resolvedTXTByID = [:]
|
||||
self.pendingTXTResolvers.values.forEach { $0.cancel() }
|
||||
self.pendingTXTResolvers = [:]
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
|
||||
beacons.map { beacon in
|
||||
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: beacon.instanceName,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: beacon.sshPort ?? 22,
|
||||
gatewayPort: beacon.gatewayPort,
|
||||
cliPath: beacon.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
self.gateways = self.gatewaysByDomain.values
|
||||
.flatMap(\.self)
|
||||
.filter { !$0.isLocal }
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
||||
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
||||
if !primaryFiltered.isEmpty {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||
// which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
|
||||
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
||||
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||
}
|
||||
|
||||
private func updateGateways(for domain: String) {
|
||||
@@ -146,6 +234,74 @@ final class GatewayDiscoveryModel {
|
||||
isLocal: isLocal)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
|
||||
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
|
||||
self.hasUsableWideAreaResults
|
||||
{
|
||||
self.wideAreaFallbackGateways = []
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleWideAreaFallback() {
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
if Self.isRunningTests { return }
|
||||
guard self.wideAreaFallbackTask == nil else { return }
|
||||
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||
let hasResults = await MainActor.run {
|
||||
self.hasUsableWideAreaResults
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
|
||||
// published yet). Retry with a short backoff while onboarding is open.
|
||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
|
||||
if !beacons.isEmpty {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
|
||||
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
if !self.filterLocalGateways { return true }
|
||||
return gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
|
||||
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
||||
var seen = Set<String>()
|
||||
let deduped = gateways.filter { gateway in
|
||||
if seen.contains(gateway.stableID) { return false }
|
||||
seen.insert(gateway.stableID)
|
||||
return true
|
||||
}
|
||||
return deduped.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static var isRunningTests: Bool {
|
||||
// Keep discovery background work from running forever during SwiftPM test runs.
|
||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return env["XCTestConfigurationFilePath"] != nil
|
||||
|| env["XCTestBundlePath"] != nil
|
||||
|| env["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
|
||||
private func updateGatewaysForAllDomains() {
|
||||
@@ -208,15 +364,15 @@ final class GatewayDiscoveryModel {
|
||||
return merged
|
||||
}
|
||||
|
||||
struct GatewayTXT: Equatable {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
public struct GatewayTXT: Equatable {
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var cliPath: String?
|
||||
}
|
||||
|
||||
static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
@@ -256,7 +412,7 @@ final class GatewayDiscoveryModel {
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
var target = "\(user)@\(host)"
|
||||
if port != 22 {
|
||||
target += ":\(port)"
|
||||
@@ -324,7 +480,7 @@ final class GatewayDiscoveryModel {
|
||||
return titled.isEmpty ? normalized : titled
|
||||
}
|
||||
|
||||
nonisolated static func isLocalGateway(
|
||||
public nonisolated static func isLocalGateway(
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
displayName: String?,
|
||||
@@ -346,18 +502,19 @@ final class GatewayDiscoveryModel {
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let service = normalizeServiceToken(serviceName) {
|
||||
for token in local.hostTokens where service.contains(token) {
|
||||
return true
|
||||
}
|
||||
if let serviceHost = normalizeServiceHostToken(serviceName),
|
||||
local.hostTokens.contains(serviceHost)
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func refreshLocalIdentity() {
|
||||
let fastIdentity = self.localIdentity
|
||||
let displayName = self.localDisplayName
|
||||
Task.detached(priority: .utility) {
|
||||
let slowIdentity = Self.buildLocalIdentitySlow()
|
||||
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
||||
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -377,7 +534,7 @@ final class GatewayDiscoveryModel {
|
||||
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentityFast() -> LocalIdentity {
|
||||
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
@@ -386,14 +543,14 @@ final class GatewayDiscoveryModel {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(InstanceIdentity.displayName) {
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentitySlow() -> LocalIdentity {
|
||||
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
@@ -403,6 +560,10 @@ final class GatewayDiscoveryModel {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
@@ -434,11 +595,14 @@ final class GatewayDiscoveryModel {
|
||||
return trimmed.lowercased()
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeServiceToken(_ raw: String?) -> String? {
|
||||
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.lowercased()
|
||||
let prettified = Self.prettifyInstanceName(raw)
|
||||
let strippedBridge = prettified.replacingOccurrences(
|
||||
of: #"\s*-?\s*bridge$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return normalizeHostToken(strippedBridge)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
|
||||
struct WideAreaGatewayBeacon: Sendable, Equatable {
|
||||
var instanceName: String
|
||||
var displayName: String
|
||||
var host: String
|
||||
var port: Int
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var sshPort: Int?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
enum WideAreaGatewayDiscovery {
|
||||
private static let maxCandidates = 40
|
||||
private static let digPath = "/usr/bin/dig"
|
||||
private static let defaultTimeoutSeconds: TimeInterval = 0.2
|
||||
private static let nameserverProbeConcurrency = 6
|
||||
|
||||
struct DiscoveryContext: Sendable {
|
||||
var tailscaleStatus: @Sendable () -> String?
|
||||
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?
|
||||
|
||||
static let live = DiscoveryContext(
|
||||
tailscaleStatus: { readTailscaleStatus() },
|
||||
dig: { args, timeout in
|
||||
runDig(args: args, timeout: timeout)
|
||||
})
|
||||
}
|
||||
|
||||
static func discover(
|
||||
timeoutSeconds: TimeInterval = 2.0,
|
||||
context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon]
|
||||
{
|
||||
let startedAt = Date()
|
||||
let remaining = {
|
||||
timeoutSeconds - Date().timeIntervalSince(startedAt)
|
||||
}
|
||||
|
||||
guard let ips = collectTailnetIPv4s(
|
||||
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
||||
var candidates = Array(ips.prefix(self.maxCandidates))
|
||||
guard let nameserver = findNameserver(
|
||||
candidates: &candidates,
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
guard let ptrLines = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||
!ptrLines.isEmpty
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
var beacons: [WideAreaGatewayBeacon] = []
|
||||
for raw in ptrLines {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
||||
|
||||
guard let srv = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||
min(defaultTimeoutSeconds, remaining()))
|
||||
else { continue }
|
||||
guard let (host, port) = parseSrv(srv) else { continue }
|
||||
|
||||
let txtRaw = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"],
|
||||
min(self.defaultTimeoutSeconds, remaining()))
|
||||
let txtTokens = txtRaw.map(self.parseTxtTokens) ?? []
|
||||
let txt = self.mapTxt(tokens: txtTokens)
|
||||
|
||||
let displayName = txt["displayName"] ?? instanceName
|
||||
let beacon = WideAreaGatewayBeacon(
|
||||
instanceName: instanceName,
|
||||
displayName: displayName,
|
||||
host: host,
|
||||
port: port,
|
||||
lanHost: txt["lanHost"],
|
||||
tailnetDns: txt["tailnetDns"],
|
||||
gatewayPort: parseInt(txt["gatewayPort"]),
|
||||
bridgePort: parseInt(txt["bridgePort"]),
|
||||
sshPort: parseInt(txt["sshPort"]),
|
||||
cliPath: txt["cliPath"])
|
||||
beacons.append(beacon)
|
||||
}
|
||||
|
||||
return beacons
|
||||
}
|
||||
|
||||
private static func collectTailnetIPv4s(statusJson: String?) -> [String] {
|
||||
guard let statusJson else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
guard let data = statusJson.data(using: .utf8),
|
||||
let status = try? decoder.decode(TailscaleStatus.self, from: data)
|
||||
else { return [] }
|
||||
|
||||
var ips: [String] = []
|
||||
ips.append(contentsOf: status.selfNode?.resolvedIPs ?? [])
|
||||
if let peers = status.peer {
|
||||
for peer in peers.values {
|
||||
ips.append(contentsOf: peer.resolvedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
let ordered = ips.filter { value in
|
||||
guard self.isTailnetIPv4(value) else { return false }
|
||||
if seen.contains(value) { return false }
|
||||
seen.insert(value)
|
||||
return true
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func readTailscaleStatus() -> String? {
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"tailscale",
|
||||
]
|
||||
|
||||
var output: String?
|
||||
for candidate in candidates {
|
||||
if let result = run(
|
||||
path: candidate,
|
||||
args: ["status", "--json"],
|
||||
timeout: 0.7)
|
||||
{
|
||||
output = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func findNameserver(
|
||||
candidates: inout [String],
|
||||
remaining: () -> TimeInterval,
|
||||
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
|
||||
{
|
||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||
|
||||
let ips = candidates
|
||||
candidates.removeAll(keepingCapacity: true)
|
||||
if ips.isEmpty { return nil }
|
||||
|
||||
final class ProbeState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var nextIndex = 0
|
||||
var found: String?
|
||||
}
|
||||
|
||||
let state = ProbeState()
|
||||
let deadline = Date().addingTimeInterval(max(0, remaining()))
|
||||
let workerCount = min(self.nameserverProbeConcurrency, ips.count)
|
||||
let group = DispatchGroup()
|
||||
|
||||
for _ in 0..<workerCount {
|
||||
group.enter()
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
defer { group.leave() }
|
||||
|
||||
while Date() < deadline {
|
||||
state.lock.lock()
|
||||
if state.found != nil {
|
||||
state.lock.unlock()
|
||||
return
|
||||
}
|
||||
let i = state.nextIndex
|
||||
state.nextIndex += 1
|
||||
state.lock.unlock()
|
||||
|
||||
if i >= ips.count { return }
|
||||
let ip = ips[i]
|
||||
let budget = deadline.timeIntervalSinceNow
|
||||
if budget <= 0 { return }
|
||||
|
||||
if let stdout = dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, budget)),
|
||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
||||
{
|
||||
state.lock.lock()
|
||||
if state.found == nil {
|
||||
state.found = ip
|
||||
}
|
||||
state.lock.unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = group.wait(timeout: .now() + max(0.0, remaining()))
|
||||
return state.found
|
||||
}
|
||||
|
||||
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
|
||||
self.run(path: self.digPath, args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
private static func run(path: String, args: [String], timeout: TimeInterval) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning, Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return output?.isEmpty == false ? output : nil
|
||||
}
|
||||
|
||||
private static func parseSrv(_ stdout: String) -> (String, Int)? {
|
||||
let line = stdout
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
guard let line else { return nil }
|
||||
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard parts.count >= 4 else { return nil }
|
||||
guard let port = Int(parts[2]), port > 0 else { return nil }
|
||||
let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3]
|
||||
return (host, port)
|
||||
}
|
||||
|
||||
private static func parseTxtTokens(_ stdout: String) -> [String] {
|
||||
let lines = stdout.split(whereSeparator: \.isNewline)
|
||||
var tokens: [String] = []
|
||||
for raw in lines {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
let matches = line.matches(of: /"([^"]*)"/)
|
||||
for match in matches {
|
||||
tokens.append(self.unescapeTxt(String(match.1)))
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private static func unescapeTxt(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
}
|
||||
|
||||
private static func mapTxt(tokens: [String]) -> [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
for token in tokens {
|
||||
guard let idx = token.firstIndex(of: "=") else { continue }
|
||||
let key = String(token[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawValue = String(token[token.index(after: idx)...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = self.decodeDnsSdEscapes(rawValue)
|
||||
if !key.isEmpty { out[key] = value }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?) -> Int? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(trimmed)
|
||||
}
|
||||
|
||||
private static func isTailnetIPv4(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: ".")
|
||||
if parts.count != 4 { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
if octets.count != 4 { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func decodeDnsSdEscapes(_ value: String) -> String {
|
||||
var bytes: [UInt8] = []
|
||||
var pending = ""
|
||||
|
||||
func flushPending() {
|
||||
guard !pending.isEmpty else { return }
|
||||
bytes.append(contentsOf: pending.utf8)
|
||||
pending = ""
|
||||
}
|
||||
|
||||
let chars = Array(value)
|
||||
var i = 0
|
||||
while i < chars.count {
|
||||
let ch = chars[i]
|
||||
if ch == "\\", i + 3 < chars.count {
|
||||
let digits = String(chars[(i + 1)...(i + 3)])
|
||||
if digits.allSatisfy(\.isNumber),
|
||||
let byte = UInt8(digits)
|
||||
{
|
||||
flushPending()
|
||||
bytes.append(byte)
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
pending.append(ch)
|
||||
i += 1
|
||||
}
|
||||
flushPending()
|
||||
|
||||
if bytes.isEmpty { return value }
|
||||
if let decoded = String(bytes: bytes, encoding: .utf8) {
|
||||
return decoded
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatus: Decodable {
|
||||
struct Node: Decodable {
|
||||
let tailscaleIPs: [String]?
|
||||
|
||||
var resolvedIPs: [String] {
|
||||
self.tailscaleIPs ?? []
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tailscaleIPs = "TailscaleIPs"
|
||||
}
|
||||
}
|
||||
|
||||
let selfNode: Node?
|
||||
let peer: [String: Node]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfNode = "Self"
|
||||
case peer = "Peer"
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
fileprivate var nonEmpty: Self? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
struct DiscoveryOptions {
|
||||
var timeoutMs: Int = 2000
|
||||
var json: Bool = false
|
||||
var includeLocal: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> DiscoveryOptions {
|
||||
var opts = DiscoveryOptions()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
opts.help = true
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--include-local":
|
||||
opts.includeLocal = true
|
||||
case "--timeout":
|
||||
let next = (i + 1 < args.count) ? args[i + 1] : nil
|
||||
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
opts.timeoutMs = max(100, parsed)
|
||||
i += 1
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveryOutput: Encodable {
|
||||
struct Gateway: Encodable {
|
||||
var displayName: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort: Int
|
||||
var gatewayPort: Int?
|
||||
var cliPath: String?
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var isLocal: Bool
|
||||
}
|
||||
|
||||
var status: String
|
||||
var timeoutMs: Int
|
||||
var includeLocal: Bool
|
||||
var count: Int
|
||||
var gateways: [Gateway]
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotDiscoveryCLI {
|
||||
static func main() async {
|
||||
let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
print("""
|
||||
clawdbot-mac-discovery
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-discovery [--timeout <ms>] [--json] [--include-local]
|
||||
|
||||
Options:
|
||||
--timeout <ms> Discovery window in milliseconds (default: 2000)
|
||||
--json Emit JSON
|
||||
--include-local Include gateways considered local
|
||||
-h, --help Show help
|
||||
""")
|
||||
return
|
||||
}
|
||||
|
||||
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
let model = GatewayDiscoveryModel(
|
||||
localDisplayName: displayName,
|
||||
filterLocalGateways: !opts.includeLocal)
|
||||
|
||||
await MainActor.run {
|
||||
model.start()
|
||||
}
|
||||
|
||||
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: nanos)
|
||||
|
||||
let gateways = await MainActor.run { model.gateways }
|
||||
let status = await MainActor.run { model.statusText }
|
||||
|
||||
await MainActor.run {
|
||||
model.stop()
|
||||
}
|
||||
|
||||
if opts.json {
|
||||
let payload = DiscoveryOutput(
|
||||
status: status,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
includeLocal: opts.includeLocal,
|
||||
count: gateways.count,
|
||||
gateways: gateways.map {
|
||||
DiscoveryOutput.Gateway(
|
||||
displayName: $0.displayName,
|
||||
lanHost: $0.lanHost,
|
||||
tailnetDns: $0.tailnetDns,
|
||||
sshPort: $0.sshPort,
|
||||
gatewayPort: $0.gatewayPort,
|
||||
cliPath: $0.cliPath,
|
||||
stableID: $0.stableID,
|
||||
debugID: $0.debugID,
|
||||
isLocal: $0.isLocal)
|
||||
})
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
print(json)
|
||||
} else {
|
||||
print("{\"error\":\"failed to encode JSON\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
print("Gateway Discovery (macOS NWBrowser)")
|
||||
print("Status: \(status)")
|
||||
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
|
||||
if gateways.isEmpty { return }
|
||||
|
||||
for gateway in gateways {
|
||||
let hosts = [gateway.tailnetDns, gateway.lanHost]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: ", ")
|
||||
print("- \(gateway.displayName)")
|
||||
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
|
||||
print(" ssh: \(gateway.sshPort)")
|
||||
if let port = gateway.gatewayPort {
|
||||
print(" gatewayPort: \(port)")
|
||||
}
|
||||
if let cliPath = gateway.cliPath {
|
||||
print(" cliPath: \(cliPath)")
|
||||
}
|
||||
print(" isLocal: \(gateway.isLocal)")
|
||||
print(" stableID: \(gateway.stableID)")
|
||||
print(" debugID: \(gateway.debugID)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,11 +421,14 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let deliver: Bool?
|
||||
public let attachments: [AnyCodable]?
|
||||
public let provider: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
@@ -434,11 +437,14 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
provider: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
idempotencykey: String
|
||||
idempotencykey: String,
|
||||
label: String?,
|
||||
spawnedby: String?
|
||||
) {
|
||||
self.message = message
|
||||
self.to = to
|
||||
@@ -446,11 +452,14 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.provider = provider
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
@@ -459,11 +468,14 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case attachments
|
||||
case provider
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,35 +675,78 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
limit: Int?,
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
spawnedby: String?
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?
|
||||
) {
|
||||
self.limit = limit
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let label: AnyCodable?
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let reasoninglevel: AnyCodable?
|
||||
public let responseusage: AnyCodable?
|
||||
public let elevatedlevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let spawnedby: AnyCodable?
|
||||
@@ -700,9 +755,11 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
label: AnyCodable?,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
reasoninglevel: AnyCodable?,
|
||||
responseusage: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
spawnedby: AnyCodable?,
|
||||
@@ -710,9 +767,11 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.reasoninglevel = reasoninglevel
|
||||
self.responseusage = responseusage
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.model = model
|
||||
self.spawnedby = spawnedby
|
||||
@@ -721,9 +780,11 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case label
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case reasoninglevel = "reasoningLevel"
|
||||
case responseusage = "responseUsage"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case model
|
||||
case spawnedby = "spawnedBy"
|
||||
@@ -1100,6 +1161,51 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentSummary: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct AgentsListResult: Codable, Sendable {
|
||||
public let defaultid: String
|
||||
public let mainkey: String
|
||||
public let scope: AnyCodable
|
||||
public let agents: [AgentSummary]
|
||||
|
||||
public init(
|
||||
defaultid: String,
|
||||
mainkey: String,
|
||||
scope: AnyCodable,
|
||||
agents: [AgentSummary]
|
||||
) {
|
||||
self.defaultid = defaultid
|
||||
self.mainkey = mainkey
|
||||
self.scope = scope
|
||||
self.agents = agents
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case defaultid = "defaultId"
|
||||
case mainkey = "mainKey"
|
||||
case scope
|
||||
case agents
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelChoice: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
@@ -1527,11 +1633,11 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let runid: String
|
||||
public let runid: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
runid: String
|
||||
runid: String?
|
||||
) {
|
||||
self.sessionkey = sessionkey
|
||||
self.runid = runid
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ClawdbotDiscovery
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite
|
||||
@MainActor
|
||||
@@ -64,6 +64,24 @@ struct GatewayDiscoveryModelTests {
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func serviceNameDoesNotFalsePositiveOnSubstringHostToken() {
|
||||
let local = GatewayDiscoveryModel.LocalIdentity(
|
||||
hostTokens: ["steipete"],
|
||||
displayTokens: [])
|
||||
#expect(!GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: "steipetacstudio (Clawdbot)",
|
||||
local: local))
|
||||
#expect(GatewayDiscoveryModel.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
displayName: nil,
|
||||
serviceName: "steipete (Clawdbot)",
|
||||
local: local))
|
||||
}
|
||||
|
||||
@Test func parsesGatewayTXTFields() {
|
||||
let parsed = GatewayDiscoveryModel.parseGatewayTXT([
|
||||
"lanHost": " studio.local ",
|
||||
|
||||
@@ -57,6 +57,25 @@ struct LowCoverageHelperTests {
|
||||
#expect(result.timedOut == true)
|
||||
}
|
||||
|
||||
@Test func shellExecutorDrainsStdoutAndStderr() async {
|
||||
let script = """
|
||||
i=0
|
||||
while [ $i -lt 2000 ]; do
|
||||
echo "stdout-$i"
|
||||
echo "stderr-$i" 1>&2
|
||||
i=$((i+1))
|
||||
done
|
||||
"""
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: ["/bin/sh", "-c", script],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 2)
|
||||
#expect(result.success == true)
|
||||
#expect(result.stdout.contains("stdout-1999"))
|
||||
#expect(result.stderr.contains("stderr-1999"))
|
||||
}
|
||||
|
||||
@Test func pairedNodesStorePersists() async throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
||||
|
||||