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 | ||
|
|
805969b598 | ||
|
|
27952bb94d |
No files matched your search
@@ -5,6 +5,57 @@ 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: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
@@ -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,7 +136,7 @@ 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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +35,8 @@
|
||||
- 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.
|
||||
|
||||
@@ -41,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`.
|
||||
@@ -65,6 +69,9 @@
|
||||
## 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.
|
||||
@@ -87,7 +94,7 @@
|
||||
- **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/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.
|
||||
- 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.
|
||||
|
||||
@@ -1,130 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 2026.1.10
|
||||
|
||||
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
|
||||
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
|
||||
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
|
||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
||||
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
|
||||
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
|
||||
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
|
||||
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
|
||||
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
|
||||
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
|
||||
- Commands: accept /models as an alias for /model.
|
||||
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
|
||||
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75
|
||||
- Commands: harden slash command registry and list text-only commands in `/commands`.
|
||||
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
|
||||
- Debugging: add raw model stream logging flags and document gateway watch mode.
|
||||
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
|
||||
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
|
||||
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
|
||||
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
|
||||
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
|
||||
- Hooks: normalize hook agent providers (aliases + msteams support).
|
||||
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
|
||||
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
|
||||
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
|
||||
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles.
|
||||
- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence.
|
||||
- Agent: enable adaptive context pruning by default for tool-result trimming.
|
||||
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
|
||||
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
|
||||
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
|
||||
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
|
||||
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik
|
||||
- Doctor: run legacy state migrations in non-interactive mode without prompts.
|
||||
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
|
||||
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
|
||||
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
|
||||
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
|
||||
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
|
||||
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
|
||||
- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994
|
||||
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
|
||||
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
|
||||
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
|
||||
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
|
||||
- Auto-reply: relax reply tag parsing to allow whitespace. (#560) — thanks @mcinteerj
|
||||
- Auto-reply: fix /status usage summary filtering for the active provider.
|
||||
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
|
||||
- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
|
||||
- Status: show active auth profile and key snippet in /status.
|
||||
- Status: show provider usage windows when auth uses token-based OAuth (e.g. Claude setup-token).
|
||||
- Agent: promote `<think>`/`<thinking>` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers.
|
||||
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
|
||||
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
|
||||
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
|
||||
- Control UI: logs tab opens at the newest entries (bottom).
|
||||
- Control UI: default to relative paths for control UI assets. (#569) — thanks @bjesuiter
|
||||
- Control UI: add Docs link, remove chat composer divider, and add New session button.
|
||||
- Control UI: link sessions list to chat view. (#471) — thanks @HazAT
|
||||
- Sessions: support session `label` in store/list/UI and allow `sessions_send` lookup by label. (#570) — thanks @azade-c
|
||||
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
|
||||
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
|
||||
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
|
||||
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
|
||||
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
|
||||
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
|
||||
- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly.
|
||||
- Messages: default inbound/outbound prefixes from the routed agent’s `identity.name` when set. (#578) — thanks @p6l-richard
|
||||
- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist
|
||||
- Agent system prompt: avoid automatic self-updates unless explicitly requested.
|
||||
- Onboarding: tighten QuickStart hint copy for configuring later.
|
||||
- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik
|
||||
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.
|
||||
- Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset.
|
||||
- Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker).
|
||||
- Onboarding: clarify WhatsApp owner number prompt and label pairing phone number.
|
||||
- Auto-reply: normalize routed replies to drop NO_REPLY and apply response prefixes.
|
||||
- Commands: add /debug for runtime config overrides (memory-only).
|
||||
- Daemon runtime: remove Bun from selection options.
|
||||
- CLI: restore hidden `gateway-daemon` alias for legacy launchd configs.
|
||||
- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order.
|
||||
- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth.
|
||||
- Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf
|
||||
- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings.
|
||||
- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints.
|
||||
- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits.
|
||||
- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide.
|
||||
- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups.
|
||||
- Docs: add Ansible installation guide. (#545) — thanks @pasogott
|
||||
- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`.
|
||||
- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher
|
||||
- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj
|
||||
- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy
|
||||
- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne
|
||||
- Status: show Verbose/Elevated only when enabled.
|
||||
- Status: filter usage summary to the active model provider.
|
||||
- Status: map model providers to usage sources so unrelated usage doesn’t appear.
|
||||
- Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
|
||||
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
|
||||
- Commands: keep multi-directive messages from clearing directive handling.
|
||||
- Commands: warn when /elevated runs in direct (unsandboxed) runtime.
|
||||
- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond.
|
||||
- Commands: return /status in directive-only multi-line messages.
|
||||
- Models: fall back to configured models when the provider catalog is unavailable.
|
||||
- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist
|
||||
- Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19
|
||||
- Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true).
|
||||
- Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete
|
||||
- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete
|
||||
- Gateway/CLI: support remote loopback gateways via SSH tunnel in `clawdbot gateway status` (`--ssh` / `--ssh-auto`). — thanks @steipete
|
||||
- Gateway/Discovery: include `gatewayPort`/`sshPort`/`cliPath` in wide-area Bonjour records, and add a tailnet DNS fallback for `gateway discover` when split DNS isn’t configured. — thanks @steipete
|
||||
- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete
|
||||
- CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete
|
||||
- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md), and reset wipes config/creds/sessions/workspace (subcommand --dev no longer collides with global --dev profile). — thanks @steipete
|
||||
- Configure: stop prompting to open the Control UI (still shown in onboarding). — thanks @steipete
|
||||
- Configure: add wizard mode to remove a provider config block. — thanks @steipete
|
||||
- Onboarding/TUI: prompt to start TUI (best option) when BOOTSTRAP.md exists and add `tui --message` to auto-send the first prompt. — thanks @steipete
|
||||
- Telegram: suppress grammY getUpdates stack traces; log concise retry message instead. — thanks @steipete
|
||||
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
|
||||
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
|
||||
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
|
||||
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
|
||||
### 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
|
||||
|
||||
@@ -141,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>`.
|
||||
- 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">
|
||||
@@ -98,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.
|
||||
@@ -159,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
|
||||
@@ -312,7 +313,7 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
|
||||
### [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.
|
||||
|
||||
@@ -457,16 +458,19 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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=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/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/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>
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -403,10 +480,8 @@ struct ConfigSettings: View {
|
||||
}()
|
||||
if !loadedModel.isEmpty {
|
||||
self.configModel = loadedModel
|
||||
self.customModel = loadedModel
|
||||
} else {
|
||||
self.configModel = SessionLoader.fallbackModel
|
||||
self.customModel = SessionLoader.fallbackModel
|
||||
}
|
||||
|
||||
if let heartbeatEvery {
|
||||
@@ -459,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
|
||||
@@ -472,7 +546,6 @@ struct ConfigSettings: View {
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
@@ -498,8 +571,7 @@ struct ConfigSettings: View {
|
||||
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 {
|
||||
var model = defaults["model"] as? [String: Any] ?? [:]
|
||||
@@ -678,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 = []
|
||||
@@ -707,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
|
||||
@@ -722,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)
|
||||
|
||||
@@ -20,12 +20,14 @@ actor MacNodeBridgeSession {
|
||||
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?
|
||||
|
||||
@@ -35,10 +37,12 @@ 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
|
||||
@@ -83,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",
|
||||
@@ -97,53 +102,67 @@ actor MacNodeBridgeSession {
|
||||
} 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 "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 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 {
|
||||
@@ -205,6 +224,8 @@ actor MacNodeBridgeSession {
|
||||
self.pingTask?.cancel()
|
||||
self.pingTask = nil
|
||||
self.lastPongAt = nil
|
||||
self.disconnectHandler = nil
|
||||
self.cancelInvokeTasks()
|
||||
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
@@ -312,6 +333,7 @@ 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()
|
||||
@@ -331,9 +353,12 @@ actor MacNodeBridgeSession {
|
||||
let now = self.clock.now
|
||||
if now > last.advanced(by: timeout) {
|
||||
let age = last.duration(to: now)
|
||||
self.logger.warning(
|
||||
"Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -342,9 +367,12 @@ actor MacNodeBridgeSession {
|
||||
do {
|
||||
try await self.send(BridgePing(type: "ping", id: id))
|
||||
} catch {
|
||||
self.logger.warning(
|
||||
"Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -358,17 +386,50 @@ actor MacNodeBridgeSession {
|
||||
private func handleConnectionState(_ state: NWConnection.State) async {
|
||||
switch state {
|
||||
case let .failed(error):
|
||||
self.logger.warning(
|
||||
"Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
|
||||
await self.disconnect()
|
||||
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.disconnect()
|
||||
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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,6 +421,7 @@ 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?
|
||||
@@ -436,6 +437,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
provider: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
@@ -450,6 +452,7 @@ 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
|
||||
@@ -465,6 +468,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case deliver
|
||||
case attachments
|
||||
case provider
|
||||
case timeout
|
||||
case lane
|
||||
@@ -1629,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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@@ -16,4 +17,3 @@ struct MacNodeBridgeSessionTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@@ -6,7 +7,7 @@ import Testing
|
||||
@MainActor
|
||||
struct MasterDiscoveryMenuSmokeTests {
|
||||
@Test func inlineListBuildsBodyWhenEmpty() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Searching…"
|
||||
discovery.gateways = []
|
||||
|
||||
@@ -15,7 +16,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
}
|
||||
|
||||
@Test func inlineListBuildsBodyWithMasterAndSelection() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 1"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
@@ -23,6 +24,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: "office.local",
|
||||
tailnetDns: "office.tailnet-123.ts.net",
|
||||
sshPort: 2222,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "office",
|
||||
debugID: "office",
|
||||
isLocal: false),
|
||||
@@ -34,7 +37,7 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
}
|
||||
|
||||
@Test func menuBuildsBodyWithMasters() {
|
||||
let discovery = GatewayDiscoveryModel()
|
||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||
discovery.statusText = "Found 2"
|
||||
discovery.gateways = [
|
||||
GatewayDiscoveryModel.DiscoveredGateway(
|
||||
@@ -42,6 +45,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: "a.local",
|
||||
tailnetDns: nil,
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "a",
|
||||
debugID: "a",
|
||||
isLocal: false),
|
||||
@@ -50,6 +55,8 @@ struct MasterDiscoveryMenuSmokeTests {
|
||||
lanHost: nil,
|
||||
tailnetDns: "b.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "b",
|
||||
debugID: "b",
|
||||
isLocal: false),
|
||||
|
||||
@@ -23,7 +23,7 @@ struct MenuSessionsInjectorTests {
|
||||
let injector = MenuSessionsInjector()
|
||||
injector.setTestingControlChannelConnected(true)
|
||||
|
||||
let defaults = SessionDefaults(model: "claude-opus-4-5", contextTokens: 200_000)
|
||||
let defaults = SessionDefaults(model: "anthropic/claude-opus-4-5", contextTokens: 200_000)
|
||||
let rows = [
|
||||
SessionRow(
|
||||
id: "main",
|
||||
@@ -66,6 +66,24 @@ struct MenuSessionsInjectorTests {
|
||||
rows: rows)
|
||||
injector.setTestingSnapshot(snapshot, errorText: nil)
|
||||
|
||||
let usage = GatewayUsageSummary(
|
||||
updatedAt: Date().timeIntervalSince1970 * 1000,
|
||||
providers: [
|
||||
GatewayUsageProvider(
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [GatewayUsageWindow(label: "5h", usedPercent: 12, resetAt: nil)],
|
||||
plan: "Pro",
|
||||
error: nil),
|
||||
GatewayUsageProvider(
|
||||
provider: "openai-codex",
|
||||
displayName: "Codex",
|
||||
windows: [GatewayUsageWindow(label: "day", usedPercent: 3, resetAt: nil)],
|
||||
plan: nil,
|
||||
error: nil),
|
||||
])
|
||||
injector.setTestingUsageSummary(usage, errorText: nil)
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.addItem(NSMenuItem(title: "Header", action: nil, keyEquivalent: ""))
|
||||
menu.addItem(.separator())
|
||||
@@ -73,5 +91,6 @@ struct MenuSessionsInjectorTests {
|
||||
|
||||
injector.injectForTesting(into: menu)
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 })
|
||||
#expect(menu.items.contains { $0.tag == 9_415_557 && $0.isSeparatorItem })
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotDiscovery
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@@ -10,7 +11,7 @@ struct OnboardingViewSmokeTests {
|
||||
let view = OnboardingView(
|
||||
state: state,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel())
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import CoreLocation
|
||||
import Testing
|
||||
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite("PermissionManager Location")
|
||||
struct PermissionManagerLocationTests {
|
||||
@Test("authorizedAlways counts for both modes")
|
||||
func authorizedAlwaysCountsForBothModes() {
|
||||
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))
|
||||
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: true))
|
||||
}
|
||||
|
||||
@Test("other statuses not authorized")
|
||||
func otherStatusesNotAuthorized() {
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .notDetermined, requireAlways: false))
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .denied, requireAlways: false))
|
||||
#expect(!PermissionManager.isLocationAuthorized(status: .restricted, requireAlways: false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Testing
|
||||
@testable import ClawdbotDiscovery
|
||||
|
||||
@Suite
|
||||
struct WideAreaGatewayDiscoveryTests {
|
||||
@Test func discoversBeaconFromTailnetDnsSdFallback() {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": { "TailscaleIPs": ["100.69.232.64"] },
|
||||
"Peer": {
|
||||
"peer-1": { "TailscaleIPs": ["100.123.224.76"] }
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = WideAreaGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
dig: { args, _ in
|
||||
let recordType = args.last ?? ""
|
||||
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||
if recordType == "PTR" {
|
||||
if nameserver == "@100.123.224.76" {
|
||||
return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if recordType == "SRV" {
|
||||
return "0 0 18790 steipetacstudio.clawdbot.internal."
|
||||
}
|
||||
if recordType == "TXT" {
|
||||
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
let beacons = WideAreaGatewayDiscovery.discover(
|
||||
timeoutSeconds: 2.0,
|
||||
context: context)
|
||||
|
||||
#expect(beacons.count == 1)
|
||||
let beacon = beacons[0]
|
||||
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
|
||||
#expect(beacon.displayName == expectedDisplay)
|
||||
#expect(beacon.bridgePort == 18790)
|
||||
#expect(beacon.gatewayPort == 18789)
|
||||
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
|
||||
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,10 @@ public struct ClawdbotChatView: View {
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
ClawdbotChatTheme.background
|
||||
.ignoresSafeArea()
|
||||
if self.style == .standard {
|
||||
ClawdbotChatTheme.background
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
VStack(spacing: Layout.stackSpacing) {
|
||||
self.messageList
|
||||
|
||||
@@ -112,24 +112,19 @@ public final class ClawdbotChatViewModel {
|
||||
recent.append(entry)
|
||||
}
|
||||
|
||||
let mainKey = "main"
|
||||
var result: [ClawdbotChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
if let main = sorted.first(where: { $0.key == mainKey }) {
|
||||
result.append(main)
|
||||
included.insert(mainKey)
|
||||
} else if self.sessionKey == mainKey {
|
||||
result.append(self.placeholderSession(key: mainKey))
|
||||
included.insert(mainKey)
|
||||
}
|
||||
|
||||
for entry in recent where !included.contains(entry.key) {
|
||||
result.append(entry)
|
||||
included.insert(entry.key)
|
||||
}
|
||||
|
||||
if !included.contains(self.sessionKey) {
|
||||
result.append(self.placeholderSession(key: self.sessionKey))
|
||||
if let current = sorted.first(where: { $0.key == self.sessionKey }) {
|
||||
result.append(current)
|
||||
} else {
|
||||
result.append(self.placeholderSession(key: self.sessionKey))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
enum AsyncTimeout {
|
||||
static func withTimeout<T: Sendable>(
|
||||
public enum AsyncTimeout {
|
||||
public static func withTimeout<T: Sendable>(
|
||||
seconds: Double,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, seconds)
|
||||
if clamped == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
@@ -19,4 +23,14 @@ enum AsyncTimeout {
|
||||
throw onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
public static func withTimeoutMs<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
onTimeout: @escaping @Sendable () -> Error,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
let clamped = max(0, timeoutMs)
|
||||
let seconds = Double(clamped) / 1000.0
|
||||
return try await self.withTimeout(seconds: seconds, onTimeout: onTimeout, operation: operation)
|
||||
}
|
||||
}
|
||||
@@ -91,16 +91,85 @@ public enum ToolDisplayRegistry {
|
||||
|
||||
private static func loadConfig() -> ToolDisplayConfig {
|
||||
guard let url = ClawdbotKitResources.bundle.url(forResource: "tool-display", withExtension: "json") else {
|
||||
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
|
||||
return self.defaultConfig()
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(ToolDisplayConfig.self, from: data)
|
||||
} catch {
|
||||
return ToolDisplayConfig(version: nil, fallback: nil, tools: nil)
|
||||
return self.defaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultConfig() -> ToolDisplayConfig {
|
||||
ToolDisplayConfig(
|
||||
version: 1,
|
||||
fallback: ToolDisplaySpec(
|
||||
emoji: "🧩",
|
||||
title: nil,
|
||||
label: nil,
|
||||
detailKeys: [
|
||||
"command",
|
||||
"path",
|
||||
"url",
|
||||
"targetUrl",
|
||||
"targetId",
|
||||
"ref",
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
"guildId",
|
||||
"userId",
|
||||
"name",
|
||||
"query",
|
||||
"pattern",
|
||||
"messageId",
|
||||
],
|
||||
actions: nil),
|
||||
tools: [
|
||||
"bash": ToolDisplaySpec(
|
||||
emoji: "🛠️",
|
||||
title: "Bash",
|
||||
label: nil,
|
||||
detailKeys: ["command"],
|
||||
actions: nil),
|
||||
"read": ToolDisplaySpec(
|
||||
emoji: "📖",
|
||||
title: "Read",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"write": ToolDisplaySpec(
|
||||
emoji: "✍️",
|
||||
title: "Write",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"edit": ToolDisplaySpec(
|
||||
emoji: "📝",
|
||||
title: "Edit",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"attach": ToolDisplaySpec(
|
||||
emoji: "📎",
|
||||
title: "Attach",
|
||||
label: nil,
|
||||
detailKeys: ["path", "url", "fileName"],
|
||||
actions: nil),
|
||||
"process": ToolDisplaySpec(
|
||||
emoji: "🧰",
|
||||
title: "Process",
|
||||
label: nil,
|
||||
detailKeys: ["sessionId"],
|
||||
actions: nil),
|
||||
])
|
||||
}
|
||||
|
||||
private static func titleFromName(_ name: String) -> String {
|
||||
let cleaned = name.replacingOccurrences(of: "_", with: " ").trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return "Tool" }
|
||||
@@ -122,8 +191,10 @@ public enum ToolDisplayRegistry {
|
||||
|
||||
private static func readDetail(_ args: AnyCodable?) -> String? {
|
||||
guard let path = valueForKeyPath(args, path: "path") as? String else { return nil }
|
||||
let offset = self.valueForKeyPath(args, path: "offset") as? Double
|
||||
let limit = self.valueForKeyPath(args, path: "limit") as? Double
|
||||
let offsetAny = self.valueForKeyPath(args, path: "offset")
|
||||
let limitAny = self.valueForKeyPath(args, path: "limit")
|
||||
let offset = (offsetAny as? Double) ?? (offsetAny as? Int).map(Double.init)
|
||||
let limit = (limitAny as? Double) ?? (limitAny as? Int).map(Double.init)
|
||||
if let offset, let limit {
|
||||
let end = offset + limit
|
||||
return "\(path):\(Int(offset))-\(Int(end))"
|
||||
|
||||
@@ -3,7 +3,10 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
|
||||
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
|
||||
IMAGE_NAME="${CLAWDBOT_IMAGE:-clawdbot:local}"
|
||||
EXTRA_MOUNTS="${CLAWDBOT_EXTRA_MOUNTS:-}"
|
||||
HOME_VOLUME_NAME="${CLAWDBOT_HOME_VOLUME:-}"
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
@@ -27,6 +30,7 @@ export CLAWDBOT_GATEWAY_PORT="${CLAWDBOT_GATEWAY_PORT:-18789}"
|
||||
export CLAWDBOT_BRIDGE_PORT="${CLAWDBOT_BRIDGE_PORT:-18790}"
|
||||
export CLAWDBOT_GATEWAY_BIND="${CLAWDBOT_GATEWAY_BIND:-lan}"
|
||||
export CLAWDBOT_IMAGE="$IMAGE_NAME"
|
||||
export CLAWDBOT_DOCKER_APT_PACKAGES="${CLAWDBOT_DOCKER_APT_PACKAGES:-}"
|
||||
|
||||
if [[ -z "${CLAWDBOT_GATEWAY_TOKEN:-}" ]]; then
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
@@ -41,6 +45,78 @@ PY
|
||||
fi
|
||||
export CLAWDBOT_GATEWAY_TOKEN
|
||||
|
||||
COMPOSE_FILES=("$COMPOSE_FILE")
|
||||
COMPOSE_ARGS=()
|
||||
|
||||
write_extra_compose() {
|
||||
local home_volume="$1"
|
||||
shift
|
||||
local -a mounts=("$@")
|
||||
local mount
|
||||
|
||||
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
|
||||
services:
|
||||
clawdbot-gateway:
|
||||
volumes:
|
||||
YAML
|
||||
|
||||
if [[ -n "$home_volume" ]]; then
|
||||
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
for mount in "${mounts[@]}"; do
|
||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
done
|
||||
|
||||
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
|
||||
clawdbot-cli:
|
||||
volumes:
|
||||
YAML
|
||||
|
||||
if [[ -n "$home_volume" ]]; then
|
||||
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s:/home/node/.clawdbot\n' "$CLAWDBOT_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
|
||||
printf ' - %s:/home/node/clawd\n' "$CLAWDBOT_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
for mount in "${mounts[@]}"; do
|
||||
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
|
||||
done
|
||||
|
||||
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
|
||||
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
|
||||
volumes:
|
||||
${home_volume}:
|
||||
YAML
|
||||
fi
|
||||
}
|
||||
|
||||
VALID_MOUNTS=()
|
||||
if [[ -n "$EXTRA_MOUNTS" ]]; then
|
||||
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
|
||||
for mount in "${mounts[@]}"; do
|
||||
mount="${mount#"${mount%%[![:space:]]*}"}"
|
||||
mount="${mount%"${mount##*[![:space:]]}"}"
|
||||
if [[ -n "$mount" ]]; then
|
||||
VALID_MOUNTS+=("$mount")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
|
||||
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
|
||||
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
|
||||
fi
|
||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||
COMPOSE_ARGS+=("-f" "$compose_file")
|
||||
done
|
||||
COMPOSE_HINT="docker compose"
|
||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||
COMPOSE_HINT+=" -f ${compose_file}"
|
||||
done
|
||||
|
||||
ENV_FILE="$ROOT_DIR/.env"
|
||||
upsert_env() {
|
||||
local file="$1"
|
||||
@@ -84,10 +160,17 @@ upsert_env "$ENV_FILE" \
|
||||
CLAWDBOT_BRIDGE_PORT \
|
||||
CLAWDBOT_GATEWAY_BIND \
|
||||
CLAWDBOT_GATEWAY_TOKEN \
|
||||
CLAWDBOT_IMAGE
|
||||
CLAWDBOT_IMAGE \
|
||||
CLAWDBOT_EXTRA_MOUNTS \
|
||||
CLAWDBOT_HOME_VOLUME \
|
||||
CLAWDBOT_DOCKER_APT_PACKAGES
|
||||
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/Dockerfile" "$ROOT_DIR"
|
||||
docker build \
|
||||
--build-arg "CLAWDBOT_DOCKER_APT_PACKAGES=${CLAWDBOT_DOCKER_APT_PACKAGES}" \
|
||||
-t "$IMAGE_NAME" \
|
||||
-f "$ROOT_DIR/Dockerfile" \
|
||||
"$ROOT_DIR"
|
||||
|
||||
echo ""
|
||||
echo "==> Onboarding (interactive)"
|
||||
@@ -98,21 +181,21 @@ echo " - Gateway token: $CLAWDBOT_GATEWAY_TOKEN"
|
||||
echo " - Tailscale exposure: Off"
|
||||
echo " - Install Gateway daemon: No"
|
||||
echo ""
|
||||
docker compose -f "$COMPOSE_FILE" run --rm clawdbot-cli onboard
|
||||
docker compose "${COMPOSE_ARGS[@]}" run --rm clawdbot-cli onboard --no-install-daemon
|
||||
|
||||
echo ""
|
||||
echo "==> Provider setup (optional)"
|
||||
echo "WhatsApp (QR):"
|
||||
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers login"
|
||||
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers login"
|
||||
echo "Telegram (bot token):"
|
||||
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider telegram --token <token>"
|
||||
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider telegram --token <token>"
|
||||
echo "Discord (bot token):"
|
||||
echo " docker compose -f $COMPOSE_FILE run --rm clawdbot-cli providers add --provider discord --token <token>"
|
||||
echo " ${COMPOSE_HINT} run --rm clawdbot-cli providers add --provider discord --token <token>"
|
||||
echo "Docs: https://docs.clawd.bot/providers"
|
||||
|
||||
echo ""
|
||||
echo "==> Starting gateway"
|
||||
docker compose -f "$COMPOSE_FILE" up -d clawdbot-gateway
|
||||
docker compose "${COMPOSE_ARGS[@]}" up -d clawdbot-gateway
|
||||
|
||||
echo ""
|
||||
echo "Gateway running with host port mapping."
|
||||
@@ -122,5 +205,5 @@ echo "Workspace: $CLAWDBOT_WORKSPACE_DIR"
|
||||
echo "Token: $CLAWDBOT_GATEWAY_TOKEN"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " docker compose -f $COMPOSE_FILE logs -f clawdbot-gateway"
|
||||
echo " docker compose -f $COMPOSE_FILE exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
|
||||
echo " ${COMPOSE_HINT} logs -f clawdbot-gateway"
|
||||
echo " ${COMPOSE_HINT} exec clawdbot-gateway node dist/index.js health --token \"$CLAWDBOT_GATEWAY_TOKEN\""
|
||||
@@ -1,4 +1,4 @@
|
||||
title: "CLAWDBOT Docs"
|
||||
title: "Clawdbot Docs"
|
||||
description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)."
|
||||
markdown: kramdown
|
||||
highlighter: rouge
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<header class="shell">
|
||||
<div class="shell__frame" role="banner">
|
||||
<div class="shell__titlebar">
|
||||
<div class="brand" aria-label="CLAWDBOT docs terminal">
|
||||
<div class="brand" aria-label="Clawdbot docs terminal">
|
||||
<img
|
||||
class="brand__logo"
|
||||
src="{{ "/assets/pixel-lobster.svg" | relative_url }}"
|
||||
@@ -45,7 +45,7 @@
|
||||
decoding="async"
|
||||
/>
|
||||
<div class="brand__text">
|
||||
<div class="brand__name">CLAWDBOT</div>
|
||||
<div class="brand__name">Clawdbot</div>
|
||||
<div class="brand__hint">docs // lobster terminal</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -13,7 +13,7 @@ Goal: Gmail watch -> Pub/Sub push -> `gog gmail watch serve` -> Clawdbot webhook
|
||||
|
||||
- `gcloud` installed and logged in ([install guide](https://docs.cloud.google.com/sdk/docs/install-sdk)).
|
||||
- `gog` (gogcli) installed and authorized for the Gmail account ([gogcli.sh](https://gogcli.sh/)).
|
||||
- Clawdbot hooks enabled (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
|
||||
- Clawdbot hooks enabled (see [Webhooks](/automation/webhook)).
|
||||
- `tailscale` logged in ([tailscale.com](https://tailscale.com/)). Supported setup uses Tailscale Funnel for the public HTTPS endpoint.
|
||||
Other tunnel services can work, but are DIY/unsupported and require manual wiring.
|
||||
Right now, Tailscale is what we support.
|
||||
@@ -65,8 +65,27 @@ uses the last delivery route (falls back to WhatsApp).
|
||||
To force a cheaper model for Gmail runs, set `model` in the mapping
|
||||
(`provider/model` or alias). If you enforce `agents.defaults.models`, include it there.
|
||||
|
||||
To set a default model and thinking level specifically for Gmail hooks, add
|
||||
`hooks.gmail.model` / `hooks.gmail.thinking` in your config:
|
||||
|
||||
```json5
|
||||
{
|
||||
hooks: {
|
||||
gmail: {
|
||||
model: "openrouter/meta-llama/llama-3.3-70b-instruct:free",
|
||||
thinking: "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
|
||||
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
|
||||
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
|
||||
|
||||
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
||||
under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)).
|
||||
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
||||
|
||||
## Wizard (recommended)
|
||||
|
||||
@@ -86,6 +105,9 @@ Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
|
||||
`hooks.gmail.serve.path` to `/` and keeps the public path at
|
||||
`hooks.gmail.tailscale.path` (default `/gmail-pubsub`) because Tailscale
|
||||
strips the set-path prefix before proxying.
|
||||
If you need the backend to receive the prefixed path, set
|
||||
`hooks.gmail.tailscale.target` (or `--tailscale-target`) to a full URL like
|
||||
`http://127.0.0.1:8788/gmail-pubsub` and match `hooks.gmail.serve.path`.
|
||||
|
||||
Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Payload:
|
||||
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
|
||||
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||
@@ -97,7 +97,7 @@ Mapping options (summary):
|
||||
- Set `deliver: true` + `provider`/`to` on mappings to route replies to a chat surface
|
||||
(`provider` defaults to `last` and falls back to WhatsApp).
|
||||
- `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
|
||||
See [`docs/gmail-pubsub.md`](https://docs.clawd.bot/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||
|
||||
## Responses
|
||||
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
summary: "Broadcast a WhatsApp message to multiple agents"
|
||||
read_when:
|
||||
- Configuring broadcast groups
|
||||
- Debugging multi-agent replies in WhatsApp
|
||||
status: experimental
|
||||
---
|
||||
|
||||
# Broadcast Groups
|
||||
|
||||
**Status:** Experimental
|
||||
**Version:** Added in 2026.1.9
|
||||
|
||||
## Overview
|
||||
|
||||
Broadcast Groups enable multiple agents to process and respond to the same message simultaneously. This allows you to create specialized agent teams that work together in a single WhatsApp group or DM — all using one phone number.
|
||||
|
||||
Current scope: **WhatsApp only** (web provider).
|
||||
|
||||
Broadcast groups are evaluated after provider allowlists and group activation rules. In WhatsApp groups, this means broadcasts happen when Clawdbot would normally reply (for example: on mention, depending on your group settings).
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Specialized Agent Teams
|
||||
Deploy multiple agents with atomic, focused responsibilities:
|
||||
```
|
||||
Group: "Development Team"
|
||||
Agents:
|
||||
- CodeReviewer (reviews code snippets)
|
||||
- DocumentationBot (generates docs)
|
||||
- SecurityAuditor (checks for vulnerabilities)
|
||||
- TestGenerator (suggests test cases)
|
||||
```
|
||||
|
||||
Each agent processes the same message and provides its specialized perspective.
|
||||
|
||||
### 2. Multi-Language Support
|
||||
```
|
||||
Group: "International Support"
|
||||
Agents:
|
||||
- Agent_EN (responds in English)
|
||||
- Agent_DE (responds in German)
|
||||
- Agent_ES (responds in Spanish)
|
||||
```
|
||||
|
||||
### 3. Quality Assurance Workflows
|
||||
```
|
||||
Group: "Customer Support"
|
||||
Agents:
|
||||
- SupportAgent (provides answer)
|
||||
- QAAgent (reviews quality, only responds if issues found)
|
||||
```
|
||||
|
||||
### 4. Task Automation
|
||||
```
|
||||
Group: "Project Management"
|
||||
Agents:
|
||||
- TaskTracker (updates task database)
|
||||
- TimeLogger (logs time spent)
|
||||
- ReportGenerator (creates summaries)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Setup
|
||||
|
||||
Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer ids:
|
||||
- group chats: group JID (e.g. `120363403215116621@g.us`)
|
||||
- DMs: E.164 phone number (e.g. `+15551234567`)
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"120363403215116621@g.us": ["alfred", "baerbel", "assistant3"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** When Clawdbot would reply in this chat, it will run all three agents.
|
||||
|
||||
### Processing Strategy
|
||||
|
||||
Control how agents process messages:
|
||||
|
||||
#### Parallel (Default)
|
||||
All agents process simultaneously:
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Sequential
|
||||
Agents process in order (one waits for previous to finish):
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"120363403215116621@g.us": ["alfred", "baerbel"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "code-reviewer",
|
||||
"name": "Code Reviewer",
|
||||
"workspace": "/path/to/code-reviewer",
|
||||
"sandbox": { "mode": "all" }
|
||||
},
|
||||
{
|
||||
"id": "security-auditor",
|
||||
"name": "Security Auditor",
|
||||
"workspace": "/path/to/security-auditor",
|
||||
"sandbox": { "mode": "all" }
|
||||
},
|
||||
{
|
||||
"id": "docs-generator",
|
||||
"name": "Documentation Generator",
|
||||
"workspace": "/path/to/docs-generator",
|
||||
"sandbox": { "mode": "all" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": ["code-reviewer", "security-auditor", "docs-generator"],
|
||||
"120363424282127706@g.us": ["support-en", "support-de"],
|
||||
"+15555550123": ["assistant", "logger"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Message Flow
|
||||
|
||||
1. **Incoming message** arrives in a WhatsApp group
|
||||
2. **Broadcast check**: System checks if peer ID is in `broadcast`
|
||||
3. **If in broadcast list**:
|
||||
- All listed agents process the message
|
||||
- Each agent has its own session key and isolated context
|
||||
- Agents process in parallel (default) or sequentially
|
||||
4. **If not in broadcast list**:
|
||||
- Normal routing applies (first matching binding)
|
||||
|
||||
Note: broadcast groups do not bypass provider allowlists or group activation rules (mentions/commands/etc). They only change *which agents run* when a message is eligible for processing.
|
||||
|
||||
### Session Isolation
|
||||
|
||||
Each agent in a broadcast group maintains completely separate:
|
||||
|
||||
- **Session keys** (`agent:alfred:whatsapp:group:120363...` vs `agent:baerbel:whatsapp:group:120363...`)
|
||||
- **Conversation history** (agent doesn't see other agents' messages)
|
||||
- **Workspace** (separate sandboxes if configured)
|
||||
- **Tool access** (different allow/deny lists)
|
||||
- **Memory/context** (separate IDENTITY.md, SOUL.md, etc.)
|
||||
- **Group context buffer** (recent group messages used for context) is shared per peer, so all broadcast agents see the same context when triggered
|
||||
|
||||
This allows each agent to have:
|
||||
- Different personalities
|
||||
- Different tool access (e.g., read-only vs. read-write)
|
||||
- Different models (e.g., opus vs. sonnet)
|
||||
- Different skills installed
|
||||
|
||||
### Example: Isolated Sessions
|
||||
|
||||
In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
|
||||
|
||||
**Alfred's context:**
|
||||
```
|
||||
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, alfred's previous responses]
|
||||
Workspace: /Users/pascal/clawd-alfred/
|
||||
Tools: read, write, bash
|
||||
```
|
||||
|
||||
**Bärbel's context:**
|
||||
```
|
||||
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
|
||||
History: [user message, baerbel's previous responses]
|
||||
Workspace: /Users/pascal/clawd-baerbel/
|
||||
Tools: read only
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Agents Focused
|
||||
|
||||
Design each agent with a single, clear responsibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"DEV_GROUP": ["formatter", "linter", "tester"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Good:** Each agent has one job
|
||||
❌ **Bad:** One generic "dev-helper" agent
|
||||
|
||||
### 2. Use Descriptive Names
|
||||
|
||||
Make it clear what each agent does:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"security-scanner": { "name": "Security Scanner" },
|
||||
"code-formatter": { "name": "Code Formatter" },
|
||||
"test-generator": { "name": "Test Generator" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure Different Tool Access
|
||||
|
||||
Give agents only the tools they need:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"tools": { "allow": ["read", "bash"] } // Read-only
|
||||
},
|
||||
"fixer": {
|
||||
"tools": { "allow": ["read", "write", "edit", "bash"] } // Read-write
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Monitor Performance
|
||||
|
||||
With many agents, consider:
|
||||
- Using `"strategy": "parallel"` (default) for speed
|
||||
- Limiting broadcast groups to 5-10 agents
|
||||
- Using faster models for simpler agents
|
||||
|
||||
### 5. Handle Failures Gracefully
|
||||
|
||||
Agents fail independently. One agent's error doesn't block others:
|
||||
|
||||
```
|
||||
Message → [Agent A ✓, Agent B ✗ error, Agent C ✓]
|
||||
Result: Agent A and C respond, Agent B logs error
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Providers
|
||||
|
||||
Broadcast groups currently work with:
|
||||
- ✅ WhatsApp (implemented)
|
||||
- 🚧 Telegram (planned)
|
||||
- 🚧 Discord (planned)
|
||||
- 🚧 Slack (planned)
|
||||
|
||||
### Routing
|
||||
|
||||
Broadcast groups work alongside existing routing:
|
||||
|
||||
```json
|
||||
{
|
||||
"bindings": [
|
||||
{ "match": { "provider": "whatsapp", "peer": { "kind": "group", "id": "GROUP_A" } }, "agentId": "alfred" }
|
||||
],
|
||||
"broadcast": {
|
||||
"GROUP_B": ["agent1", "agent2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `GROUP_A`: Only alfred responds (normal routing)
|
||||
- `GROUP_B`: agent1 AND agent2 respond (broadcast)
|
||||
|
||||
**Precedence:** `broadcast` takes priority over `bindings`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agents Not Responding
|
||||
|
||||
**Check:**
|
||||
1. Agent IDs exist in `agents.list`
|
||||
2. Peer ID format is correct (e.g., `120363403215116621@g.us`)
|
||||
3. Agents are not in deny lists
|
||||
|
||||
**Debug:**
|
||||
```bash
|
||||
tail -f ~/.clawdbot/logs/gateway.log | grep broadcast
|
||||
```
|
||||
|
||||
### Only One Agent Responding
|
||||
|
||||
**Cause:** Peer ID might be in `bindings` but not `broadcast`.
|
||||
|
||||
**Fix:** Add to broadcast config or remove from bindings.
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**If slow with many agents:**
|
||||
- Reduce number of agents per group
|
||||
- Use lighter models (sonnet instead of opus)
|
||||
- Check sandbox startup time
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Review Team
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "parallel",
|
||||
"120363403215116621@g.us": [
|
||||
"code-formatter",
|
||||
"security-scanner",
|
||||
"test-coverage",
|
||||
"docs-checker"
|
||||
]
|
||||
},
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "code-formatter", "workspace": "~/agents/formatter", "tools": { "allow": ["read", "write"] } },
|
||||
{ "id": "security-scanner", "workspace": "~/agents/security", "tools": { "allow": ["read", "bash"] } },
|
||||
{ "id": "test-coverage", "workspace": "~/agents/testing", "tools": { "allow": ["read", "bash"] } },
|
||||
{ "id": "docs-checker", "workspace": "~/agents/docs", "tools": { "allow": ["read"] } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**User sends:** Code snippet
|
||||
**Responses:**
|
||||
- code-formatter: "Fixed indentation and added type hints"
|
||||
- security-scanner: "⚠️ SQL injection vulnerability in line 12"
|
||||
- test-coverage: "Coverage is 45%, missing tests for error cases"
|
||||
- docs-checker: "Missing docstring for function `process_data`"
|
||||
|
||||
### Example 2: Multi-Language Support
|
||||
|
||||
```json
|
||||
{
|
||||
"broadcast": {
|
||||
"strategy": "sequential",
|
||||
"+15555550123": ["detect-language", "translator-en", "translator-de"]
|
||||
},
|
||||
"agents": {
|
||||
"list": [
|
||||
{ "id": "detect-language", "workspace": "~/agents/lang-detect" },
|
||||
{ "id": "translator-en", "workspace": "~/agents/translate-en" },
|
||||
{ "id": "translator-de", "workspace": "~/agents/translate-de" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Config Schema
|
||||
|
||||
```typescript
|
||||
interface ClawdbotConfig {
|
||||
broadcast?: {
|
||||
strategy?: "parallel" | "sequential";
|
||||
[peerId: string]: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- `strategy` (optional): How to process agents
|
||||
- `"parallel"` (default): All agents process simultaneously
|
||||
- `"sequential"`: Agents process in array order
|
||||
|
||||
- `[peerId]`: WhatsApp group JID, E.164 number, or other peer ID
|
||||
- Value: Array of agent IDs that should process messages
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Max agents:** No hard limit, but 10+ agents may be slow
|
||||
2. **Shared context:** Agents don't see each other's responses (by design)
|
||||
3. **Message ordering:** Parallel responses may arrive in any order
|
||||
4. **Rate limits:** All agents count toward WhatsApp rate limits
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features:
|
||||
- [ ] Shared context mode (agents see each other's responses)
|
||||
- [ ] Agent coordination (agents can signal each other)
|
||||
- [ ] Dynamic agent selection (choose agents based on message content)
|
||||
- [ ] Agent priorities (some agents respond before others)
|
||||
|
||||
## See Also
|
||||
|
||||
- [Multi-Agent Configuration](/multi-agent-sandbox-tools)
|
||||
- [Routing Configuration](/concepts/provider-routing)
|
||||
- [Session Management](/concepts/sessions)
|
||||
@@ -39,6 +39,7 @@ Notes:
|
||||
- `--password <password>`: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process).
|
||||
- `--tailscale <off|serve|funnel>`: expose the Gateway via Tailscale.
|
||||
- `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown.
|
||||
- `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config.
|
||||
- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md).
|
||||
- `--reset`: reset dev config + credentials + sessions + workspace (requires `--dev`).
|
||||
- `--force`: kill any existing listener on the selected port before starting.
|
||||
@@ -77,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
|
||||
- your configured remote gateway (if set), and
|
||||
- localhost (loopback) **even if remote is configured**.
|
||||
|
||||
If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway).
|
||||
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
|
||||
|
||||
```bash
|
||||
clawdbot gateway status
|
||||
|
||||
@@ -14,6 +14,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports.
|
||||
- `--profile <name>`: isolate state under `~/.clawdbot-<name>`.
|
||||
- `--no-color`: disable ANSI colors.
|
||||
- `--update`: shorthand for `clawdbot update` (source installs only).
|
||||
- `-V`, `--version`, `-v`: print version and exit.
|
||||
|
||||
## Output styling
|
||||
@@ -47,9 +48,11 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
onboard
|
||||
configure (alias: config)
|
||||
doctor
|
||||
update
|
||||
providers
|
||||
list
|
||||
status
|
||||
logs
|
||||
add
|
||||
remove
|
||||
login
|
||||
@@ -72,6 +75,14 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
health
|
||||
status
|
||||
discover
|
||||
daemon
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
logs
|
||||
models
|
||||
list
|
||||
status
|
||||
@@ -81,6 +92,12 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
fallbacks list|add|remove|clear
|
||||
image-fallbacks list|add|remove|clear
|
||||
scan
|
||||
auth add|setup-token|paste-token
|
||||
auth order get|set|clear
|
||||
sandbox
|
||||
list
|
||||
recreate
|
||||
explain
|
||||
wake
|
||||
cron
|
||||
status
|
||||
@@ -104,7 +121,8 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
run
|
||||
notify
|
||||
camera list|snap|clip
|
||||
canvas snapshot
|
||||
canvas snapshot|present|hide|navigate|eval
|
||||
canvas a2ui push|reset
|
||||
screen record
|
||||
location get
|
||||
browser
|
||||
@@ -153,7 +171,8 @@ Chat messages support `/...` commands (text and native). See [/tools/slash-comma
|
||||
|
||||
Highlights:
|
||||
- `/status` for quick diagnostics.
|
||||
- `/debug` for runtime-only config overrides (memory, not disk).
|
||||
- `/config` for persisted config changes.
|
||||
- `/debug` for runtime-only config overrides (memory, not disk; requires `commands.debug: true`).
|
||||
|
||||
## Setup + onboarding
|
||||
|
||||
@@ -175,9 +194,11 @@ Interactive wizard to set up gateway, workspace, and skills.
|
||||
|
||||
Options:
|
||||
- `--workspace <dir>`
|
||||
- `--reset` (reset config + credentials + sessions + workspace before wizard)
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -185,7 +206,9 @@ Options:
|
||||
- `--anthropic-api-key <key>`
|
||||
- `--openai-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||
- `--gateway-auth <off|token|password>`
|
||||
@@ -196,9 +219,12 @@ Options:
|
||||
- `--tailscale <off|serve|funnel>`
|
||||
- `--tailscale-reset-on-exit`
|
||||
- `--install-daemon`
|
||||
- `--no-install-daemon` (alias: `--skip-daemon`)
|
||||
- `--daemon-runtime <node|bun>`
|
||||
- `--skip-providers`
|
||||
- `--skip-skills`
|
||||
- `--skip-health`
|
||||
- `--skip-ui`
|
||||
- `--node-manager <npm|pnpm|bun>`
|
||||
- `--json`
|
||||
|
||||
@@ -217,19 +243,20 @@ Options:
|
||||
## Provider helpers
|
||||
|
||||
### `providers`
|
||||
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage).
|
||||
Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
|
||||
|
||||
Subcommands:
|
||||
- `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
||||
- `providers status`: check gateway reachability and provider health (`--probe` to verify credentials and run small provider audits; use `status --deep` for local-only probes).
|
||||
- `providers status`: check gateway reachability and provider health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes).
|
||||
- Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
|
||||
- `providers logs`: show recent provider logs from the gateway log file.
|
||||
- `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode.
|
||||
- `providers remove`: disable by default; pass `--delete` to remove config entries without prompts.
|
||||
- `providers login`: interactive provider login (WhatsApp Web only).
|
||||
- `providers logout`: log out of a provider session (WhatsApp Web only).
|
||||
|
||||
Common options:
|
||||
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage`
|
||||
- `--provider <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
|
||||
- `--account <id>`: provider account id (default `default`)
|
||||
- `--name <label>`: display name for the account
|
||||
|
||||
@@ -246,6 +273,11 @@ Common options:
|
||||
- `--no-usage`: skip provider usage/quota snapshots (OAuth/API-backed only).
|
||||
- `--json`: output JSON (includes usage unless `--no-usage` is set).
|
||||
|
||||
`providers logs` options:
|
||||
- `--provider <name|all>` (default `all`)
|
||||
- `--lines <n>` (default `200`)
|
||||
- `--json`
|
||||
|
||||
OAuth sync sources:
|
||||
- Claude Code → `anthropic:claude-cli`
|
||||
- macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts)
|
||||
@@ -282,14 +314,14 @@ Tip: use `npx clawdhub` to search, install, and sync skills.
|
||||
Approve DM pairing requests across providers.
|
||||
|
||||
Subcommands:
|
||||
- `pairing list --provider <telegram|signal|imessage|discord|slack|whatsapp> [--json]`
|
||||
- `pairing approve --provider <...> <code> [--notify]`
|
||||
- `pairing list <provider> [--json]`
|
||||
- `pairing approve <provider> <code> [--notify]`
|
||||
|
||||
### `hooks gmail`
|
||||
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
|
||||
|
||||
Subcommands:
|
||||
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--push-endpoint`, `--json`)
|
||||
- `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
|
||||
- `hooks gmail run` (runtime overrides for the same flags)
|
||||
|
||||
### `dns setup`
|
||||
@@ -372,10 +404,12 @@ Show linked session health and recent recipients.
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--all` (full diagnosis; read-only, pasteable)
|
||||
- `--deep` (probe providers)
|
||||
- `--usage` (show provider usage/quota)
|
||||
- `--timeout <ms>`
|
||||
- `--verbose`
|
||||
- `--debug` (alias for `--verbose`)
|
||||
|
||||
### Usage tracking
|
||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||
@@ -426,8 +460,11 @@ Options:
|
||||
- `--reset` (reset dev config + credentials + sessions + workspace)
|
||||
- `--force` (kill existing listener on port)
|
||||
- `--verbose`
|
||||
- `--claude-cli-logs`
|
||||
- `--ws-log <auto|full|compact>`
|
||||
- `--compact` (alias for `--ws-log compact`)
|
||||
- `--raw-stream`
|
||||
- `--raw-stream-path <path>`
|
||||
|
||||
### `daemon`
|
||||
Manage the Gateway service (launchd/systemd/schtasks).
|
||||
@@ -443,7 +480,7 @@ Subcommands:
|
||||
Notes:
|
||||
- `daemon status` probes the Gateway RPC by default using the daemon’s resolved port/config (override with `--url/--token/--password`).
|
||||
- `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting.
|
||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans).
|
||||
- `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named Clawdbot services are treated as first-class and aren't flagged as "extra".
|
||||
- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL.
|
||||
- `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled.
|
||||
- `daemon install` options: `--port`, `--runtime`, `--token`, `--force`.
|
||||
@@ -471,6 +508,7 @@ Subcommands:
|
||||
- `gateway call <method> [--params <json>]`
|
||||
- `gateway health`
|
||||
- `gateway status`
|
||||
- `gateway discover`
|
||||
|
||||
Common RPCs:
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
@@ -490,6 +528,10 @@ clawdbot models status
|
||||
### `models` (root)
|
||||
`clawdbot models` is an alias for `models status`.
|
||||
|
||||
Root options:
|
||||
- `--status-json` (alias for `models status --json`)
|
||||
- `--status-plain` (alias for `models status --plain`)
|
||||
|
||||
### `models list`
|
||||
Options:
|
||||
- `--all`
|
||||
@@ -540,12 +582,25 @@ Options:
|
||||
- `--max-candidates <n>`
|
||||
- `--timeout <ms>`
|
||||
- `--concurrency <n>`
|
||||
- `--no-probe`
|
||||
- `--yes`
|
||||
- `--no-input`
|
||||
- `--set-default`
|
||||
- `--set-image`
|
||||
- `--json`
|
||||
|
||||
### `models auth add|setup-token|paste-token`
|
||||
Options:
|
||||
- `add`: interactive auth helper
|
||||
- `setup-token`: `--provider <name>` (default `anthropic`), `--yes`
|
||||
- `paste-token`: `--provider <name>`, `--profile-id <id>`, `--expires-in <duration>`
|
||||
|
||||
### `models auth order get|set|clear`
|
||||
Options:
|
||||
- `get`: `--provider <name>`, `--agent <id>`, `--json`
|
||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||
- `clear`: `--provider <name>`, `--agent <id>`
|
||||
|
||||
## Cron + wake
|
||||
|
||||
### `wake`
|
||||
@@ -673,5 +728,5 @@ Options:
|
||||
- `--deliver`
|
||||
- `--thinking <level>`
|
||||
- `--message <text>`
|
||||
- `--timeout-ms <ms>`
|
||||
- `--timeout-ms <ms>` (defaults to `agents.defaults.timeoutSeconds`)
|
||||
- `--history-limit <n>`
|
||||
@@ -24,9 +24,10 @@ Provider selection:
|
||||
Target formats (`--to`):
|
||||
- WhatsApp: E.164 or group JID
|
||||
- Telegram: chat id or `@username`
|
||||
- Discord/Slack: `channel:<id>` or `user:<id>` (raw id ok)
|
||||
- Signal: E.164, `group:<id>`, or `signal:+E.164`
|
||||
- iMessage: handle or `chat_id:<id>`
|
||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are rejected)
|
||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||
|
||||
## Common flags
|
||||
@@ -42,60 +43,82 @@ Target formats (`--to`):
|
||||
### Core
|
||||
|
||||
- `send`
|
||||
- Providers: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
|
||||
- Required: `--to`, `--message`
|
||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||
- Telegram only: `--buttons-json` (requires `"inlineButtons"` in `telegram.capabilities` or `telegram.accounts.<id>.capabilities`)
|
||||
- Telegram only: `--thread-id` (forum topic id)
|
||||
- Slack only: `--thread-id` (thread timestamp; `--reply-to` uses the same field)
|
||||
- WhatsApp only: `--gif-playback`
|
||||
|
||||
- `poll`
|
||||
- Providers: WhatsApp/Discord/MS Teams
|
||||
- Required: `--to`, `--poll-question`, `--poll-option` (repeat)
|
||||
- Optional: `--poll-multi`, `--poll-duration-hours`, `--message`
|
||||
- Optional: `--poll-multi`
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
|
||||
- `react`
|
||||
- Required: `--to`, `--message-id`
|
||||
- Providers: Discord/Slack/Telegram/WhatsApp
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
|
||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||
- WhatsApp only: `--participant`, `--from-me`
|
||||
|
||||
- `reactions`
|
||||
- Required: `--to`, `--message-id`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--limit`, `--channel-id`
|
||||
|
||||
- `read`
|
||||
- Required: `--to`
|
||||
- Optional: `--limit`, `--before`, `--after`, `--around`, `--channel-id`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--limit`, `--before`, `--after`, `--channel-id`
|
||||
- Discord only: `--around`
|
||||
|
||||
- `edit`
|
||||
- Required: `--to`, `--message-id`, `--message`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--message-id`, `--message`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `delete`
|
||||
- Required: `--to`, `--message-id`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `pin` / `unpin`
|
||||
- Required: `--to`, `--message-id`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--message-id`, `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `pins` (list)
|
||||
- Required: `--to`
|
||||
- Providers: Discord/Slack
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `permissions`
|
||||
- Required: `--to`
|
||||
- Providers: Discord
|
||||
- Required: `--to` or `--channel-id`
|
||||
- Optional: `--channel-id`
|
||||
|
||||
- `search`
|
||||
- Providers: Discord
|
||||
- Required: `--guild-id`, `--query`
|
||||
- Optional: `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`
|
||||
|
||||
### Threads
|
||||
|
||||
- `thread create`
|
||||
- Providers: Discord
|
||||
- Required: `--thread-name`, `--to` (channel id) or `--channel-id`
|
||||
- Optional: `--message-id`, `--auto-archive-min`
|
||||
|
||||
- `thread list`
|
||||
- Providers: Discord
|
||||
- Required: `--guild-id`
|
||||
- Optional: `--channel-id`, `--include-archived`, `--before`, `--limit`
|
||||
|
||||
- `thread reply`
|
||||
- Providers: Discord
|
||||
- Required: `--to` (thread id), `--message`
|
||||
- Optional: `--media`, `--reply-to`
|
||||
|
||||
@@ -103,18 +126,22 @@ Target formats (`--to`):
|
||||
|
||||
- `emoji list`
|
||||
- Discord: `--guild-id`
|
||||
- Slack: no extra flags
|
||||
|
||||
- `emoji upload`
|
||||
- Providers: Discord
|
||||
- Required: `--guild-id`, `--emoji-name`, `--media`
|
||||
- Optional: `--role-ids` (repeat)
|
||||
|
||||
### Stickers
|
||||
|
||||
- `sticker send`
|
||||
- Providers: Discord
|
||||
- Required: `--to`, `--sticker-id` (repeat)
|
||||
- Optional: `--message`
|
||||
|
||||
- `sticker upload`
|
||||
- Providers: Discord
|
||||
- Required: `--guild-id`, `--sticker-name`, `--sticker-desc`, `--sticker-tags`, `--media`
|
||||
|
||||
### Roles / Channels / Members / Voice
|
||||
@@ -134,9 +161,10 @@ Target formats (`--to`):
|
||||
|
||||
### Moderation (Discord)
|
||||
|
||||
- `timeout`: `--guild-id`, `--user-id` (+ `--duration-min` or `--until`)
|
||||
- `kick`: `--guild-id`, `--user-id`
|
||||
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`)
|
||||
- `timeout`: `--guild-id`, `--user-id` (optional `--duration-min` or `--until`; omit both to clear timeout)
|
||||
- `kick`: `--guild-id`, `--user-id` (+ `--reason`)
|
||||
- `ban`: `--guild-id`, `--user-id` (+ `--delete-days`, `--reason`)
|
||||
- `timeout` also supports `--reason`
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -174,3 +202,9 @@ React in Slack:
|
||||
clawdbot message react --provider slack \
|
||||
--to C123 --message-id 456 --emoji "✅"
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --provider telegram --to @mychat --message "Choose:" \
|
||||
--buttons-json '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
|
||||
```
|
||||
@@ -1,13 +1,31 @@
|
||||
---
|
||||
title: Sandbox CLI
|
||||
summary: "Manage sandbox containers and inspect effective sandbox policy"
|
||||
read_when: "You are managing sandbox containers or debugging sandbox/tool-policy behavior."
|
||||
status: active
|
||||
---
|
||||
|
||||
# Sandbox CLI
|
||||
|
||||
Manage Docker-based sandbox containers for isolated agent execution.
|
||||
|
||||
## Overview
|
||||
|
||||
ClawdBot can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes.
|
||||
Clawdbot can run agents in isolated Docker containers for security. The `sandbox` commands help you manage these containers, especially after updates or configuration changes.
|
||||
|
||||
## Commands
|
||||
|
||||
### `clawdbot sandbox explain`
|
||||
|
||||
Inspect the **effective** sandbox mode/scope/workspace access, sandbox tool policy, and elevated gates (with fix-it config key paths).
|
||||
|
||||
```bash
|
||||
clawdbot sandbox explain
|
||||
clawdbot sandbox explain --session agent:main:main
|
||||
clawdbot sandbox explain --agent work
|
||||
clawdbot sandbox explain --json
|
||||
```
|
||||
|
||||
### `clawdbot sandbox list`
|
||||
|
||||
List all sandbox containers with their status and configuration.
|
||||
@@ -56,7 +74,7 @@ docker pull clawdbot-sandbox:latest
|
||||
docker tag clawdbot-sandbox:latest clawdbot-sandbox:bookworm-slim
|
||||
|
||||
# Update config to use new image
|
||||
# Edit clawdbot.config.json: agent.sandbox.docker.image
|
||||
# Edit config: agents.defaults.sandbox.docker.image (or agents.list[].sandbox.docker.image)
|
||||
|
||||
# Recreate containers
|
||||
clawdbot sandbox recreate --all
|
||||
@@ -65,7 +83,7 @@ clawdbot sandbox recreate --all
|
||||
### After changing sandbox configuration
|
||||
|
||||
```bash
|
||||
# Edit clawdbot.config.json: agent.sandbox.*
|
||||
# Edit config: agents.defaults.sandbox.* (or agents.list[].sandbox.*)
|
||||
|
||||
# Recreate to apply new config
|
||||
clawdbot sandbox recreate --all
|
||||
@@ -89,22 +107,24 @@ clawdbot sandbox recreate --agent alfred
|
||||
|
||||
## Configuration
|
||||
|
||||
Sandbox settings are in `clawdbot.config.json`:
|
||||
Sandbox settings live in `~/.clawdbot/clawdbot.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agent": {
|
||||
"sandbox": {
|
||||
"mode": "all", // off, non-main, all
|
||||
"scope": "agent", // session, agent, shared
|
||||
"docker": {
|
||||
"image": "clawdbot-sandbox:bookworm-slim",
|
||||
"containerPrefix": "clawdbot-sbx-"
|
||||
// ... more Docker options
|
||||
},
|
||||
"prune": {
|
||||
"idleHours": 24, // Auto-prune after 24h idle
|
||||
"maxAgeDays": 7 // Auto-prune after 7 days
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"sandbox": {
|
||||
"mode": "all", // off, non-main, all
|
||||
"scope": "agent", // session, agent, shared
|
||||
"docker": {
|
||||
"image": "clawdbot-sandbox:bookworm-slim",
|
||||
"containerPrefix": "clawdbot-sbx-"
|
||||
// ... more Docker options
|
||||
},
|
||||
"prune": {
|
||||
"idleHours": 24, // Auto-prune after 24h idle
|
||||
"maxAgeDays": 7 // Auto-prune after 7 days
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional daemon restart)"
|
||||
read_when:
|
||||
- You want to update a source checkout safely
|
||||
- You need to understand `--update` shorthand behavior
|
||||
---
|
||||
|
||||
# `clawdbot update`
|
||||
|
||||
Safely update a **source checkout** (git install) of Clawdbot.
|
||||
|
||||
If you installed via **npm/pnpm/bun** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
1. Requires a clean worktree (no uncommitted changes).
|
||||
2. Fetches and rebases against `@{upstream}`.
|
||||
3. Installs deps (pnpm/bun/npm depending on the checkout).
|
||||
4. Builds + builds the Control UI.
|
||||
5. Runs `clawdbot doctor` as the final “safe update” check.
|
||||
|
||||
## `--update` shorthand
|
||||
|
||||
`clawdbot --update` rewrites to `clawdbot update` (useful for shells and launcher scripts).
|
||||
|
||||
## See also
|
||||
|
||||
- `clawdbot doctor` (offers to run update first on git checkouts)
|
||||
- [Updating](/install/updating)
|
||||
- [CLI reference](/cli)
|
||||