mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 14:32:03 +08:00
Compare commits
958 Commits
pr-566
...
feat/slash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cc816969 | ||
|
|
b0d0914ca0 | ||
|
|
b33334620a | ||
|
|
ebc4fe57d7 | ||
|
|
5909cf36b9 | ||
|
|
e3a97b96d9 | ||
|
|
4942f3af0f | ||
|
|
ad44e32910 | ||
|
|
b0b77aae88 | ||
|
|
007f8c9222 | ||
|
|
dbd5a76cb0 | ||
|
|
630b256aee | ||
|
|
e170bc397d | ||
|
|
d2f033aee5 | ||
|
|
8f12b47b6d | ||
|
|
e8b382e8ed | ||
|
|
721a6bf0f9 | ||
|
|
25a5f1cb96 | ||
|
|
fa75d84b75 | ||
|
|
bb2df13be0 | ||
|
|
5918def440 | ||
|
|
1fdd3592d3 | ||
|
|
a96d299971 | ||
|
|
42ff634a9d | ||
|
|
b45d7c3256 | ||
|
|
77ca508274 | ||
|
|
61b7398cb7 | ||
|
|
0321d5ed74 | ||
|
|
78627ce7c2 | ||
|
|
c851bdd47a | ||
|
|
9ec0016258 | ||
|
|
01776e0569 | ||
|
|
d8f14078f0 | ||
|
|
38244b8e94 | ||
|
|
9308762d0b | ||
|
|
8eb1c76337 | ||
|
|
f94ad21f1e | ||
|
|
8d640ccc68 | ||
|
|
f566e6451f | ||
|
|
75a7855223 | ||
|
|
8f105288d2 | ||
|
|
696a6ec4d4 | ||
|
|
3271ff1d6e | ||
|
|
0efcfc0864 | ||
|
|
0aba911912 | ||
|
|
4efb5cc18e | ||
|
|
57db3f22a1 | ||
|
|
9b44c80b30 | ||
|
|
7c7f4d0eb7 | ||
|
|
ccc24e2c26 | ||
|
|
62bdbe1821 | ||
|
|
58d1d11762 | ||
|
|
8a9096cd52 | ||
|
|
b70298fbca | ||
|
|
7616b02bb1 | ||
|
|
d4c205f8e1 | ||
|
|
0ba60ff69c | ||
|
|
755a7e1b20 | ||
|
|
3061d8e057 | ||
|
|
ea5597b483 | ||
|
|
b41e75a15d | ||
|
|
bfdbaa5ab6 | ||
|
|
93ae3b8405 | ||
|
|
f249a82383 | ||
|
|
ea9486ae2d | ||
|
|
da95b58a2a | ||
|
|
e15d5d0533 | ||
|
|
1cf45f8439 | ||
|
|
2a9ef806a0 | ||
|
|
32115a8b98 | ||
|
|
7ce902b096 | ||
|
|
c4e8b60d2c | ||
|
|
c9fdd68232 | ||
|
|
50260fd385 | ||
|
|
0822f5610a | ||
|
|
2c2ca7f03b | ||
|
|
c4014c0092 | ||
|
|
c08441c42c | ||
|
|
980f274fc9 | ||
|
|
9f1f65f0e3 | ||
|
|
cddd836909 | ||
|
|
bab7eeaf91 | ||
|
|
d45915d39f | ||
|
|
fcc814accd | ||
|
|
78a3d965e0 | ||
|
|
66ad8a9289 | ||
|
|
df6634727e | ||
|
|
8b5cd97ceb | ||
|
|
25297ce3f5 | ||
|
|
3800fea962 | ||
|
|
ecb91bbb1a | ||
|
|
ab993904d7 | ||
|
|
2467a103b2 | ||
|
|
68569afb4b | ||
|
|
67d0ab3030 | ||
|
|
4024bca55d | ||
|
|
b928b96af5 | ||
|
|
cb871a3fc1 | ||
|
|
da0a062fa7 | ||
|
|
ba316a10cc | ||
|
|
4086408b10 | ||
|
|
c1f82d9ec1 | ||
|
|
eb6cace60f | ||
|
|
00f4331343 | ||
|
|
cb469ecf45 | ||
|
|
46a694bbc7 | ||
|
|
56c406b19e | ||
|
|
79a6506593 | ||
|
|
99a6fcf3f7 | ||
|
|
abe9440096 | ||
|
|
542c8020ec | ||
|
|
8edf2146ae | ||
|
|
3c8d0083cb | ||
|
|
28248f9602 | ||
|
|
d225c4a7d1 | ||
|
|
e05a8477b9 | ||
|
|
357a6e340a | ||
|
|
a87d37f26d | ||
|
|
958a4fd414 | ||
|
|
a27efd57bd | ||
|
|
d57db17300 | ||
|
|
4f1c6e76fd | ||
|
|
2111d0c653 | ||
|
|
642e6acf49 | ||
|
|
88716d8d2a | ||
|
|
c2e37c78ff | ||
|
|
5a2688c7b5 | ||
|
|
ba1d80bd00 | ||
|
|
4dfcd56893 | ||
|
|
e0ddc488d0 | ||
|
|
c012019a8a | ||
|
|
7896b30489 | ||
|
|
ffc465394e | ||
|
|
0edbdb1948 | ||
|
|
5dc187f00c | ||
|
|
231d2d5fdf | ||
|
|
20ba8d4891 | ||
|
|
b32f6a0e00 | ||
|
|
e5c77315ce | ||
|
|
dd8f7552ad | ||
|
|
95ed49ce9a | ||
|
|
92760de472 | ||
|
|
24190d09da | ||
|
|
07bdb8af7e | ||
|
|
6a48688c09 | ||
|
|
c03a745f61 | ||
|
|
48fdf3775d | ||
|
|
e5708d443a | ||
|
|
e2ea20f862 | ||
|
|
1eb924739b | ||
|
|
350f956f2c | ||
|
|
1f3ae2346e | ||
|
|
4f3bedfdb7 | ||
|
|
6f75feaeb8 | ||
|
|
e949cc383f | ||
|
|
a4bd960880 | ||
|
|
3636a2bf51 | ||
|
|
103003d9ff | ||
|
|
ce23c70855 | ||
|
|
c5fa757ef6 | ||
|
|
bb9a9633a8 | ||
|
|
ca98f87b2f | ||
|
|
df64771ecf | ||
|
|
cbe11e3de0 | ||
|
|
daa753112c | ||
|
|
2e654e8d63 | ||
|
|
cf92099d40 | ||
|
|
a55c880191 | ||
|
|
a8680f9a09 | ||
|
|
2e08a868a7 | ||
|
|
2785009c6f | ||
|
|
73d9469bf8 | ||
|
|
72100ba3ab | ||
|
|
ec5099db89 | ||
|
|
209380edf8 | ||
|
|
cf8251bb81 | ||
|
|
6ed2d69ff9 | ||
|
|
e7e544174f | ||
|
|
9b0d9db3a3 | ||
|
|
fd768334a9 | ||
|
|
9f90d0721a | ||
|
|
1920138122 | ||
|
|
27d940f5b6 | ||
|
|
7ba72aeb6c | ||
|
|
f19d37c7bb | ||
|
|
9d5bf38416 | ||
|
|
e0c1f2fdc0 | ||
|
|
08fdac0561 | ||
|
|
d3eeddfc2f | ||
|
|
0a24bc0427 | ||
|
|
9da97d1a41 | ||
|
|
29b7b2068a | ||
|
|
f4ab057807 | ||
|
|
cb35db0c7e | ||
|
|
f13db1c836 | ||
|
|
59063a7c15 | ||
|
|
5bc4971432 | ||
|
|
59c8d2d17f | ||
|
|
21405b0dfc | ||
|
|
256304037e | ||
|
|
d8ae905d54 | ||
|
|
bbc34215a2 | ||
|
|
583fc4fb11 | ||
|
|
0b2b8c7c52 | ||
|
|
5d83be76c9 | ||
|
|
877bc61b53 | ||
|
|
fcaeee7073 | ||
|
|
6857f16609 | ||
|
|
2faf7cea93 | ||
|
|
26d5cca97c | ||
|
|
99fea64823 | ||
|
|
42c17adb5e | ||
|
|
3467b0ba07 | ||
|
|
490cb834e5 | ||
|
|
cd12ad8aab | ||
|
|
eceb41f6f7 | ||
|
|
6f496b7739 | ||
|
|
e961e02f71 | ||
|
|
36a02b3e67 | ||
|
|
b73042500e | ||
|
|
6406ed869a | ||
|
|
f839d949b2 | ||
|
|
d4f7dc067e | ||
|
|
3dbfe65eea | ||
|
|
ddd4b55cf6 | ||
|
|
298c6eea1f | ||
|
|
74806aa5e3 | ||
|
|
55aeb8a0d3 | ||
|
|
86ea00dc21 | ||
|
|
a0a7e74a62 | ||
|
|
bb7397c636 | ||
|
|
7dc44b04c1 | ||
|
|
45232137a2 | ||
|
|
b1c3e38df0 | ||
|
|
0be62c3542 | ||
|
|
523f91758d | ||
|
|
5baba5f84e | ||
|
|
ffbcd83d1e | ||
|
|
1baf9f6a83 | ||
|
|
77b20377cc | ||
|
|
3ffb9a3b5e | ||
|
|
29807119d5 | ||
|
|
b88ea39b83 | ||
|
|
0a2dcd844b | ||
|
|
2ed95634fe | ||
|
|
9526f9861a | ||
|
|
7b93356fb7 | ||
|
|
17ff25bd20 | ||
|
|
d24de1ec3b | ||
|
|
44e1f271c8 | ||
|
|
8ff09f8337 | ||
|
|
e91aa0657e | ||
|
|
14801b46fc | ||
|
|
99a7548c07 | ||
|
|
35bbc2ba87 | ||
|
|
cf78d28d74 | ||
|
|
eeca541dde | ||
|
|
4bba49770d | ||
|
|
f5d5661adf | ||
|
|
f83fb70360 | ||
|
|
355c13564c | ||
|
|
f1dd59bf82 | ||
|
|
fd1e959c2d | ||
|
|
1b2c1545a0 | ||
|
|
05ac67c520 | ||
|
|
f5ee2b3a4f | ||
|
|
8afdf75e2b | ||
|
|
6aed3c0fd3 | ||
|
|
f0fbd8b012 | ||
|
|
5a3eb5ad62 | ||
|
|
79beb20ba2 | ||
|
|
3da1afed68 | ||
|
|
717a259056 | ||
|
|
adaa30c73a | ||
|
|
ff292e67ce | ||
|
|
31752aa944 | ||
|
|
bf11a42c37 | ||
|
|
8049f33435 | ||
|
|
115591c5b6 | ||
|
|
a3938d62f6 | ||
|
|
3c7a8579ad | ||
|
|
f5a9421b10 | ||
|
|
562d0e3b5f | ||
|
|
bf7e813573 | ||
|
|
c69abe08eb | ||
|
|
5a29ec78ca | ||
|
|
42b43f8c58 | ||
|
|
c1f8f1d9d0 | ||
|
|
35f8be33d2 | ||
|
|
2a875b486e | ||
|
|
c13de0b41d | ||
|
|
b9bd380ed8 | ||
|
|
6bd689a847 | ||
|
|
8fb655198f | ||
|
|
a4308a2428 | ||
|
|
ca8e2bccab | ||
|
|
83c206d68a | ||
|
|
f102d1bb9d | ||
|
|
fb6a809f91 | ||
|
|
d8feadb57a | ||
|
|
1050126132 | ||
|
|
9554292083 | ||
|
|
a8f67f0be6 | ||
|
|
d0a78da54f | ||
|
|
fadad6e061 | ||
|
|
6a7b812513 | ||
|
|
6711eaf8a5 | ||
|
|
71fdc829e6 | ||
|
|
19c96e8c0b | ||
|
|
98e75fce17 | ||
|
|
252841ab13 | ||
|
|
a7cb270999 | ||
|
|
efdf874407 | ||
|
|
7db1cbe178 | ||
|
|
1f63ee565f | ||
|
|
006e1352d8 | ||
|
|
4d075a703e | ||
|
|
3ab9d99eed | ||
|
|
842e91d019 | ||
|
|
ba3158e01a | ||
|
|
8b60003601 | ||
|
|
86a2808bff | ||
|
|
b77070cccf | ||
|
|
10a50645ef | ||
|
|
d0ba56c5ac | ||
|
|
b8f8e7f4dd | ||
|
|
3fba8ceb97 | ||
|
|
60823fd9bd | ||
|
|
e79cf5a8b1 | ||
|
|
018f7aa4df | ||
|
|
414ad72d17 | ||
|
|
e1150f1b93 | ||
|
|
d17fc7e448 | ||
|
|
4c5f78ca01 | ||
|
|
43c258c0f2 | ||
|
|
484a33f348 | ||
|
|
d5d8c01dc7 | ||
|
|
097e66391f | ||
|
|
7466575120 | ||
|
|
e19a5dc2b1 | ||
|
|
4f9a08a5cd | ||
|
|
f00667ea25 | ||
|
|
3ba2eb6298 | ||
|
|
1850013cae | ||
|
|
79cbb20988 | ||
|
|
496bad8b98 | ||
|
|
960ed66501 | ||
|
|
1a89a5dd14 | ||
|
|
5b44825cb3 | ||
|
|
1ffb0fe787 | ||
|
|
40cc7f5426 | ||
|
|
46a6d79784 | ||
|
|
20d606c4c4 | ||
|
|
e388334127 | ||
|
|
c9f2358769 | ||
|
|
4044957819 | ||
|
|
b185d130ba | ||
|
|
2bed0d78af | ||
|
|
0baf08fda1 | ||
|
|
048ee4b838 | ||
|
|
c4d85dc045 | ||
|
|
d0861670bd | ||
|
|
744fadbded | ||
|
|
0f257f792a | ||
|
|
53e04968fe | ||
|
|
285906e546 | ||
|
|
5bc5b695a9 | ||
|
|
2da2057a37 | ||
|
|
121c9bd6f3 | ||
|
|
7dbb21be8e | ||
|
|
cc8a2457c0 | ||
|
|
f5c851e11e | ||
|
|
b4a2cf8382 | ||
|
|
873cee6947 | ||
|
|
abdf4c30b2 | ||
|
|
408f52a081 | ||
|
|
51d5f16770 | ||
|
|
8e1cdf3a1f | ||
|
|
d26518687a | ||
|
|
986ff8c59f | ||
|
|
dfe5c03ba3 | ||
|
|
87f270df23 | ||
|
|
ee4dc12d51 | ||
|
|
8b4bdaa8a4 | ||
|
|
221c0b4cf8 | ||
|
|
1a25104a3d | ||
|
|
478a543e2e | ||
|
|
6a012fd625 | ||
|
|
d445cb420c | ||
|
|
1fa7a587d6 | ||
|
|
c64bcd047b | ||
|
|
d4d15c8a71 | ||
|
|
0efa6428d0 | ||
|
|
17e6354383 | ||
|
|
99877e8e63 | ||
|
|
98337a14b3 | ||
|
|
2164d1062e | ||
|
|
76c8fc8697 | ||
|
|
828d9955f2 | ||
|
|
b33bd6aaeb | ||
|
|
e3be5f8369 | ||
|
|
d97c211e82 | ||
|
|
4ced7b886e | ||
|
|
66a8a4503c | ||
|
|
fd5b168acd | ||
|
|
2941a7002d | ||
|
|
b518fb29c6 | ||
|
|
f504bfdde8 | ||
|
|
c1236e86fa | ||
|
|
12a045a0ad | ||
|
|
f57f6e0ca6 | ||
|
|
e3960cde3f | ||
|
|
3b943485f8 | ||
|
|
ecc6243edc | ||
|
|
328d47f1df | ||
|
|
9a88c94b30 | ||
|
|
1578229b8b | ||
|
|
28f97e6152 | ||
|
|
d5a2f0324e | ||
|
|
23a0bf2abe | ||
|
|
f9fe95d182 | ||
|
|
07b93e1d26 | ||
|
|
cd65183f24 | ||
|
|
0fd365a975 | ||
|
|
7c2cb57434 | ||
|
|
2a728ee68c | ||
|
|
07f1280cb0 | ||
|
|
5bdb9c0e99 | ||
|
|
b9b0d46773 | ||
|
|
6947ab18dc | ||
|
|
177ad3f06d | ||
|
|
58a12a757e | ||
|
|
b9ff4ca1fe | ||
|
|
720c5b53fd | ||
|
|
f13ae50ff8 | ||
|
|
2f4a248314 | ||
|
|
a6ea74f8e6 | ||
|
|
fa6409bca8 | ||
|
|
cf50e91bc8 | ||
|
|
9877733748 | ||
|
|
4cff7901bd | ||
|
|
0d819c21a4 | ||
|
|
056c11687b | ||
|
|
d4e9f23ee9 | ||
|
|
7d6f17d77f | ||
|
|
0ed7ea698a | ||
|
|
74526645eb | ||
|
|
cb095c8606 | ||
|
|
376d007371 | ||
|
|
01492b6515 | ||
|
|
3c81ac0315 | ||
|
|
9c8967ef5d | ||
|
|
720b9dd116 | ||
|
|
9f9f6b75e7 | ||
|
|
26cbbafc86 | ||
|
|
67743325ee | ||
|
|
32df2ef7bd | ||
|
|
ccd8950d40 | ||
|
|
86a7ab6e28 | ||
|
|
e3e3498a4b | ||
|
|
56f018ddd6 | ||
|
|
53d3134fe8 | ||
|
|
e6400b0b0f | ||
|
|
15d286b617 | ||
|
|
6b2634512c | ||
|
|
9211183f2d | ||
|
|
cd8c7f391b | ||
|
|
4b51c96e4e | ||
|
|
d9960d83c1 | ||
|
|
32affaee02 | ||
|
|
60430fcd2e | ||
|
|
55e55c8825 | ||
|
|
146f7ab433 | ||
|
|
105d0481d3 | ||
|
|
1f95d7fc8b | ||
|
|
3a8bfc0a5d | ||
|
|
26cc2bd384 | ||
|
|
248c731e78 | ||
|
|
b38155fe9a | ||
|
|
ec763a7546 | ||
|
|
4181e72977 | ||
|
|
5462cfdc3a | ||
|
|
367baaca20 | ||
|
|
e576a82c43 | ||
|
|
e5bb5b5be5 | ||
|
|
f082f1e06e | ||
|
|
0d9a1009ff | ||
|
|
33aaccd1c3 | ||
|
|
a4385dc920 | ||
|
|
ed14e1f0d0 | ||
|
|
bab4f8e628 | ||
|
|
4c3a853673 | ||
|
|
d63eae528c | ||
|
|
70ba369d65 | ||
|
|
68f6f3f0bd | ||
|
|
23717c5036 | ||
|
|
54abf4b0d7 | ||
|
|
ab9ea827a4 | ||
|
|
a005a97fef | ||
|
|
933c157092 | ||
|
|
cf0c72a557 | ||
|
|
f2b8f7bd5b | ||
|
|
7acd26a2fc | ||
|
|
23eec7d841 | ||
|
|
fe555f197c | ||
|
|
e533f99fa9 | ||
|
|
fb60637b7f | ||
|
|
5206c9f2fb | ||
|
|
a3747b1ee3 | ||
|
|
96e4fdb443 | ||
|
|
684e18bab2 | ||
|
|
f328cd5246 | ||
|
|
61b786b2b7 | ||
|
|
6b46217d19 | ||
|
|
dc3c733612 | ||
|
|
580791088c | ||
|
|
225b44ad3a | ||
|
|
99fcc82705 | ||
|
|
fb1fc5feee | ||
|
|
3da3e201de | ||
|
|
55da6ca449 | ||
|
|
28b25e8abb | ||
|
|
2ebad5af1c | ||
|
|
8dbf72099a | ||
|
|
0590365683 | ||
|
|
8e3f7c45d2 | ||
|
|
a8a4993ffd | ||
|
|
8a9831d37c | ||
|
|
314e075df2 | ||
|
|
0ef07bc142 | ||
|
|
4a166cf227 | ||
|
|
eec082e541 | ||
|
|
7006a4aad3 | ||
|
|
66fb44fbfb | ||
|
|
eb1de642db | ||
|
|
4570e1db7d | ||
|
|
17a1d302b9 | ||
|
|
11a3b5aac9 | ||
|
|
d8a13481eb | ||
|
|
a83f86a4a1 | ||
|
|
6d2928888c | ||
|
|
7551415db9 | ||
|
|
f1285be76b | ||
|
|
93cdc89daf | ||
|
|
f3f88190bb | ||
|
|
11c8db14a1 | ||
|
|
e84eb3e671 | ||
|
|
029db06477 | ||
|
|
f34d7e0fe0 | ||
|
|
587a556d6b | ||
|
|
52929c0600 | ||
|
|
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 |
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
@@ -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,7 +215,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 }}
|
||||
@@ -200,7 +251,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Runtime versions
|
||||
@@ -225,7 +276,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 }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
32
.github/workflows/install-smoke.yml
vendored
Normal file
32
.github/workflows/install-smoke.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Install Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer docker tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
run: pnpm test:install:smoke
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -1,15 +1,21 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- 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).
|
||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
@@ -34,6 +40,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 +49,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`.
|
||||
@@ -57,6 +66,7 @@
|
||||
## Security & Configuration Tips
|
||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -66,6 +76,8 @@
|
||||
- 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.
|
||||
@@ -88,6 +100,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).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- 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.
|
||||
|
||||
464
CHANGELOG.md
464
CHANGELOG.md
@@ -1,185 +1,289 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 2025.1.12 (Unreleased)
|
||||
|
||||
- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
|
||||
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
|
||||
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
|
||||
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
|
||||
- Pairing: cap pending DM pairing requests at 3 per provider and avoid pairing replies for outbound DMs. — thanks @steipete
|
||||
- macOS: replace relay smoke test with version check in packaging script. (#615) — thanks @YuriNachos
|
||||
- macOS: avoid clearing Launch at Login during app initialization. (#607) — thanks @wes-davis
|
||||
- Onboarding: skip systemd checks/daemon installs when systemd user services are unavailable; add onboarding flags to skip flow steps and stabilize Docker E2E. (#573) — thanks @steipete
|
||||
- Onboarding: QuickStart provider picker uses single-select to avoid accidental Telegram token prompts when choosing WhatsApp. (#485) — thanks @frankstallone
|
||||
- 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
|
||||
- Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
|
||||
- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
|
||||
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
|
||||
- Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
|
||||
- CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).
|
||||
- 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
|
||||
- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911
|
||||
- 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
|
||||
- Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete
|
||||
- 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
|
||||
- Models: centralize model override validation + hooks Gmail warnings in doctor. (#602) — thanks @steipete
|
||||
- Agents: avoid base-to-string error stringification in model fallback. (#604) — thanks @steipete
|
||||
- Agents: `sessions_spawn` inherits the requester's provider for child runs (avoid WhatsApp fallback). (#528) — thanks @rlmestre
|
||||
- Agents: sub-agent context now injects only AGENTS.md + TOOLS.md (omits identity/user/soul/heartbeat/bootstrap). — thanks @steipete
|
||||
- Gateway/CLI: harden agent provider routing + validation (Slack/MS Teams + aliases). (follow-up #528) — thanks @steipete
|
||||
- Agents: treat billing/insufficient-credits errors as failover-worthy so model fallbacks kick in. (#486) — thanks @steipete
|
||||
- Auth: default billing disable backoff to 5h (doubling, 24h cap) and surface disabled/cooldown profiles in `models list` + doctor. (#486) — thanks @steipete
|
||||
- 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
|
||||
- Telegram: keep streamMode draft-only; avoid forcing block streaming. (#619) — thanks @rubyrunsstuff
|
||||
- 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
|
||||
- WhatsApp: improve "no active web listener" errors (include account + relink hint). (#612) — thanks @YuriNachos
|
||||
- WhatsApp: add broadcast groups for multi-agent replies. (#547) — thanks @pasogott
|
||||
- WhatsApp: resolve @lid inbound senders via auth-dir mapping fallback + shared resolver. (#365)
|
||||
- WhatsApp: treat shared contact cards as inbound messages (prefer vCard FN). (#622) — thanks @mahmoudashraf93
|
||||
- iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann
|
||||
- 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.
|
||||
- Config: write `clawdbot.json` atomically (temp file + replace) and keep a best-effort `.bak` backup.
|
||||
- Agent: enable adaptive context pruning by default for tool-result trimming.
|
||||
- Agent: drop empty error assistant messages when sanitizing session history. (#591) — thanks @steipete
|
||||
- Agent: inject eligible skills list into the system prompt so bundled skills load from their actual locations. (#551) — thanks @gabriel-trigo
|
||||
- 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
|
||||
- WhatsApp: make inbound media size cap configurable (default 50 MB). (#505) — thanks @koala73
|
||||
- Doctor: run legacy state migrations in non-interactive mode without prompts.
|
||||
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
|
||||
- Cron: enqueue main-session system events under the resolved main session key. (#510)
|
||||
- Mobile: centralize main session key normalization for iOS/Android runtime helpers. — thanks @steipete
|
||||
- Chat UI: stop pinning hardcoded `main` session in the recent list; prefer active session if missing. — thanks @steipete
|
||||
- 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
|
||||
- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior
|
||||
- Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj
|
||||
- Agents: require `raw` for gateway `config.apply` tool calls while keeping schema 2020-12 compatible. (#566) — thanks @sircrumpet
|
||||
- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete
|
||||
- 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: add per-provider block streaming toggles and coalesce streamed blocks to reduce line spam. (#536) — thanks @mcinteerj
|
||||
- Auto-reply: suppress `<think>` leakage in block streaming and emit `/reasoning` as a separate `Reasoning:` message. (#614) — thanks @zknicker
|
||||
- Auto-reply: default block streaming off for non-Telegram providers unless explicitly enabled, and avoid splitting on forced flushes below max.
|
||||
- Auto-reply: raise default coalesce minChars for Signal/Slack/Discord and clarify streaming vs draft streaming in docs.
|
||||
- Auto-reply: default block streaming coalesce idle to 1s to reduce tiny chunks. — thanks @steipete
|
||||
- Auto-reply: fix /status usage summary filtering for the active provider.
|
||||
- Auto-reply: deduplicate followup queue entries using message id/routing to avoid duplicate replies. (#600) — thanks @samratjha96
|
||||
- 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
|
||||
- Sessions: clarify `sessions_send` delivery semantics, log announce failures, and enforce Discord request timeouts. (#507) — thanks @steipete
|
||||
- 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: refactor chat layout with tool sidebar, grouped messages, and nav improvements. (#475) — thanks @rahthakor
|
||||
- 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
|
||||
- Telegram: add inline keyboard buttons (capability-gated) and route callback query payloads as messages. (#491) — thanks @azade-c
|
||||
- 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
|
||||
- Signal: ignore reaction-only messages so they don't surface as unknown media. (#616) — thanks @neist
|
||||
- Signal: add reaction notifications with allowlist support. — thanks @steipete
|
||||
- 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
|
||||
- TUI: stop overriding agent timeout so config defaults apply; warn on invalid `--timeout-ms`. (#549)
|
||||
- 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
|
||||
- Docs: split CLI install commands into separate code blocks. (#601) — thanks @martinpucik
|
||||
- WhatsApp: record outbound provider activity using the active account id. (#537) — thanks @Nachx639
|
||||
- Discord: add gateway HELLO timeout to detect zombie connections. (#608) — thanks @NicholasSpisak
|
||||
- Docker: cache dependency layer for faster rebuilds. (#605) — thanks @zknicker
|
||||
### Highlights
|
||||
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
||||
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs).
|
||||
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
||||
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks.
|
||||
|
||||
### New & Improved
|
||||
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI.
|
||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`.
|
||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades.
|
||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides.
|
||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`.
|
||||
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs).
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Fixes
|
||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles.
|
||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors.
|
||||
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing.
|
||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors.
|
||||
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution.
|
||||
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles.
|
||||
- Auto-reply: explain how to enable `/bash` when it’s disabled; add security notes + FAQ. (#722) — thanks @vrknetha.
|
||||
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
||||
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool.
|
||||
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics.
|
||||
- Slack: accept slash commands with or without leading `/` for custom command configs.
|
||||
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params.
|
||||
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates.
|
||||
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom.
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)".
|
||||
- Connections UI: polish multi-account account cards.
|
||||
|
||||
### Maintenance
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
- Testing: update Vitest + browser-playwright to 4.0.17.
|
||||
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
|
||||
|
||||
## 2026.1.11
|
||||
|
||||
### Highlights
|
||||
- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.
|
||||
- Config: modular `$include` support for split config files. (#731) — thanks @pasogott.
|
||||
- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.
|
||||
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
|
||||
|
||||
### Changes
|
||||
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
|
||||
- CLI: configure section selection now loops until Continue.
|
||||
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
|
||||
- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).
|
||||
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
|
||||
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
|
||||
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
|
||||
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
|
||||
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
|
||||
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
|
||||
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
|
||||
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
||||
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
||||
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
|
||||
- Tests: add Docker plugin loader + tgz-install smoke test.
|
||||
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
|
||||
- Tests: add coverage for pre-compaction memory flush settings.
|
||||
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
|
||||
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
|
||||
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
|
||||
- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt.
|
||||
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott.
|
||||
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
|
||||
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
||||
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
|
||||
- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell.
|
||||
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.
|
||||
- Skills: bundle `skill-creator` to guide creating and packaging skills.
|
||||
- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf.
|
||||
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.
|
||||
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.
|
||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
||||
|
||||
### Installer
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
|
||||
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
|
||||
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
|
||||
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
|
||||
- Gateway: tighten gateway listener detection.
|
||||
- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.
|
||||
- Auth: read Codex keychain credentials and make the lookup platform-aware.
|
||||
- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.
|
||||
- Doctor: surface plugin diagnostics in the report.
|
||||
- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `clawdbot.extensions`; load plugin packages from config dirs; extract archives without system tar.
|
||||
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
|
||||
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
|
||||
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
|
||||
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
|
||||
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
||||
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
|
||||
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
||||
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
|
||||
- Heartbeat: refresh prompt text for updated defaults.
|
||||
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
|
||||
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
|
||||
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
|
||||
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
|
||||
- Agents: preserve reasoning items on tool-only turns.
|
||||
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
|
||||
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
|
||||
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
|
||||
- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.
|
||||
- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.
|
||||
- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.
|
||||
- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.
|
||||
- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state.
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
### 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).
|
||||
|
||||
### 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.
|
||||
- CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test.
|
||||
- Providers: move provider wiring to a plugin architecture. (#661).
|
||||
- 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).
|
||||
- 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
|
||||
- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output.
|
||||
- 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).
|
||||
- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram).
|
||||
- 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.
|
||||
- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee.
|
||||
- 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.
|
||||
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
|
||||
- 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
|
||||
|
||||
@@ -196,7 +300,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,6 +8,14 @@ RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
novnc \
|
||||
python3 \
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb \
|
||||
|
||||
264
README.md
264
README.md
@@ -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">
|
||||
@@ -16,26 +16,26 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/getting-started) · [Updating](https://docs.clawd.bot/updating) · [Showcase](https://docs.clawd.bot/showcase) · [FAQ](https://docs.clawd.bot/faq) · [Wizard](https://docs.clawd.bot/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/docker) · [Discord](https://discord.gg/clawd)
|
||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)
|
||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
**Subscriptions (OAuth):**
|
||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)
|
||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
@@ -54,7 +54,7 @@ The wizard installs the Gateway daemon (launchd/systemd user service) so it stay
|
||||
|
||||
Runtime: **Node ≥22**.
|
||||
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)
|
||||
Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/start/getting-started)
|
||||
|
||||
```bash
|
||||
clawdbot onboard --install-daemon
|
||||
@@ -64,11 +64,11 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).
|
||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
||||
|
||||
## From source (development)
|
||||
|
||||
@@ -94,11 +94,11 @@ Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` pro
|
||||
|
||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/security)
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/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.
|
||||
@@ -106,77 +106,82 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).
|
||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||
|
||||
### Apps + nodes
|
||||
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
|
||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
- [Provider routing](https://docs.clawd.bot/provider-routing), [retry policy](https://docs.clawd.bot/retry), and [streaming/chunking](https://docs.clawd.bot/streaming).
|
||||
- [Presence](https://docs.clawd.bot/presence), [typing indicators](https://docs.clawd.bot/typing-indicators), and [usage tracking](https://docs.clawd.bot/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/models), [model failover](https://docs.clawd.bot/model-failover), and [session pruning](https://docs.clawd.bot/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/security) and [troubleshooting](https://docs.clawd.bot/troubleshooting).
|
||||
- [Provider routing](https://docs.clawd.bot/concepts/provider-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/providers/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / 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
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
@@ -193,17 +198,17 @@ Notes:
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the bash tool and provider connections by default.
|
||||
- **Gateway host** runs the exec tool and provider connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)
|
||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
@@ -218,7 +223,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)
|
||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions_* tools)
|
||||
|
||||
@@ -227,7 +232,7 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.clawd.bot/session-tool)
|
||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
@@ -237,18 +242,18 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## macOS app (optional)
|
||||
## Apps (optional)
|
||||
|
||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||
|
||||
@@ -274,13 +279,13 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
||||
- Voice trigger forwarding + Canvas surface.
|
||||
- Controlled via `clawdbot nodes …`.
|
||||
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/ios).
|
||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
||||
|
||||
### Android node (optional)
|
||||
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/android).
|
||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@@ -300,7 +305,7 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
}
|
||||
```
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
|
||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
## Security model (important)
|
||||
|
||||
@@ -308,15 +313,15 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)
|
||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.clawd.bot/whatsapp)
|
||||
### [WhatsApp](https://docs.clawd.bot/providers/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.
|
||||
|
||||
### [Telegram](https://docs.clawd.bot/telegram)
|
||||
### [Telegram](https://docs.clawd.bot/providers/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.
|
||||
@@ -329,11 +334,11 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.clawd.bot/slack)
|
||||
### [Slack](https://docs.clawd.bot/providers/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.clawd.bot/discord)
|
||||
### [Discord](https://docs.clawd.bot/providers/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||
@@ -346,16 +351,21 @@ Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxi
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.clawd.bot/signal)
|
||||
### [Signal](https://docs.clawd.bot/providers/signal)
|
||||
|
||||
- Requires `signal-cli` and a `signal` config section.
|
||||
|
||||
### [iMessage](https://docs.clawd.bot/imessage)
|
||||
### [iMessage](https://docs.clawd.bot/providers/imessage)
|
||||
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/webchat)
|
||||
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
@@ -375,68 +385,68 @@ Browser control (optional):
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/providers/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.clawd.bot/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
|
||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/dashboard)
|
||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.clawd.bot/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
|
||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.clawd.bot/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.clawd.bot/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/rpc)
|
||||
- [Queue](https://docs.clawd.bot/queue)
|
||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.clawd.bot/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/templates/USER)
|
||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/ios)
|
||||
- [Android node](https://docs.clawd.bot/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/windows)
|
||||
- [Linux app](https://docs.clawd.bot/linux)
|
||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/gmail-pubsub)
|
||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
||||
|
||||
## Clawd
|
||||
|
||||
@@ -458,16 +468,20 @@ 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/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/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/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/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/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/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/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/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/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/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/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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/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/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/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/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/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/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/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="Sebastian Barrios" title="Sebastian Barrios"/></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/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/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/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/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/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/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a>
|
||||
<a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/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/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/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/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a>
|
||||
<a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/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/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/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/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/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/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/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/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/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/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/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/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/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/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/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a>
|
||||
<a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
47
appcast.xml
47
appcast.xml
@@ -1,55 +1,56 @@
|
||||
<?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.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</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>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-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>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<title>2026.1.11-2</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:25:53 +0000</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:version>5210</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-2</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>
|
||||
<li>Installer: ensure the CLI entrypoint is executable after npm installs.</li>
|
||||
<li>Packaging: include <code>dist/plugins/</code> in the npm package to avoid missing module errors.</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=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-2/Clawdbot-2026.1.11-2.zip" length="19860732" type="application/octet-stream" sparkle:edSignature="0UG+d9v3Qf5F9vs/KozUB404WpHjFBQRVoRuhwtzF8kpU7jJmmGlQzh1c61E+LMN4fHcljpxIwHHrvvIfRyrCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<title>2026.1.11-1</title>
|
||||
<pubDate>Mon, 12 Jan 2026 09:53:46 +0000</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:version>5207</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-1</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>
|
||||
<li>Installer: include <code>patches/</code> in the npm package so postinstall patching works for npm/bun 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-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.11-1/Clawdbot-2026.1.11-1.zip" length="19860761" type="application/octet-stream" sparkle:edSignature="CXKzzha/s6cGBeF0TMz+cV8/pfqoAL9ZyNVacYRLnnHEwA1cMbOWRftpGRhYe4HknVQYYBgNQqZK2lBxpOZgBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 20260109
|
||||
versionName = "2026.1.9"
|
||||
versionCode = 202601114
|
||||
versionName = "2026.1.11-4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -49,6 +49,7 @@ android {
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
@@ -72,6 +73,7 @@ androidComponents {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +102,7 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -161,18 +161,10 @@ actor BridgeClient {
|
||||
purpose: String,
|
||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
try await op()
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: Double(seconds),
|
||||
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
|
||||
operation: op)
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||
|
||||
@@ -115,7 +115,11 @@ final class BridgeConnectionController {
|
||||
|
||||
self.didAutoConnect = true
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||
self.startAutoConnect(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: BridgeEndpointID.stableID(endpoint),
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +136,11 @@ final class BridgeConnectionController {
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||
self.startAutoConnect(
|
||||
endpoint: target.endpoint,
|
||||
bridgeStableID: target.stableID,
|
||||
token: token,
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
@@ -171,7 +179,12 @@ final class BridgeConnectionController {
|
||||
"bridge-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||
private func startAutoConnect(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
token: String,
|
||||
instanceId: String)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -192,7 +205,10 @@ final class BridgeConnectionController {
|
||||
service: "com.clawdbot.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||
appModel.connectToBridge(
|
||||
endpoint: endpoint,
|
||||
bridgeStableID: bridgeStableID,
|
||||
hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
|
||||
@@ -321,20 +321,10 @@ actor BridgeSession {
|
||||
seconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
guard let first = try await group.next() else {
|
||||
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||
}
|
||||
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: seconds,
|
||||
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
|
||||
operation: operation)
|
||||
}
|
||||
|
||||
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>202601113</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
@@ -35,10 +35,10 @@
|
||||
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -86,24 +86,11 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T>(
|
||||
private func withTimeout<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
||||
@@ -117,26 +104,35 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation {
|
||||
self.authContinuation = nil
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let locs = locations
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
let err = error
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +204,14 @@ final class NodeAppModel {
|
||||
|
||||
func connectToBridge(
|
||||
endpoint: NWEndpoint,
|
||||
bridgeStableID: String,
|
||||
hello: BridgeHello)
|
||||
{
|
||||
self.bridgeTask?.cancel()
|
||||
self.bridgeServerName = nil
|
||||
self.bridgeRemoteAddress = nil
|
||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -175,7 +175,6 @@ private func withKeychainValues<T>(
|
||||
}
|
||||
|
||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||
let defaults = UserDefaults.standard
|
||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||
|
||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>202601113</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,6 +5,10 @@ options:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
|
||||
packages:
|
||||
ClawdbotKit:
|
||||
path: ../shared/ClawdbotKit
|
||||
@@ -68,11 +72,15 @@ targets:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios
|
||||
PROVISIONING_PROFILE_SPECIFIER: "com.clawdbot.ios Development"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
info:
|
||||
path: Sources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -84,8 +92,20 @@ targets:
|
||||
NSBonjourServices:
|
||||
- _clawdbot-bridge._tcp
|
||||
NSCameraUsageDescription: Clawdbot can capture photos or short video clips when requested via the bridge.
|
||||
NSLocationWhenInUseUsageDescription: Clawdbot uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: Clawdbot can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: Clawdbot needs microphone access for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: Clawdbot uses on-device speech recognition for voice wake.
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
|
||||
ClawdbotTests:
|
||||
type: bundle.unit-test
|
||||
@@ -96,13 +116,17 @@ targets:
|
||||
- target: Clawdbot
|
||||
- package: Swabble
|
||||
product: SwabbleKit
|
||||
- sdk: AppIntents.framework
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.clawdbot.ios.tests
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Clawdbot.app/Clawdbot"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
info:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.9"
|
||||
CFBundleVersion: "20260109"
|
||||
|
||||
@@ -10,7 +10,10 @@ 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"]),
|
||||
.executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
@@ -36,10 +39,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 +74,30 @@ let package = Package(
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotDiscoveryCLI",
|
||||
dependencies: [
|
||||
"ClawdbotDiscovery",
|
||||
],
|
||||
path: "Sources/ClawdbotDiscoveryCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.executableTarget(
|
||||
name: "ClawdbotWizardCLI",
|
||||
dependencies: [
|
||||
"ClawdbotProtocol",
|
||||
],
|
||||
path: "Sources/ClawdbotWizardCLI",
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdbotIPCTests",
|
||||
dependencies: [
|
||||
"ClawdbotIPC",
|
||||
"Clawdbot",
|
||||
"ClawdbotDiscovery",
|
||||
"ClawdbotProtocol",
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
],
|
||||
|
||||
@@ -182,14 +182,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var attachExistingGatewayOnly: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTarget: String {
|
||||
didSet {
|
||||
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
|
||||
@@ -212,7 +204,7 @@ final class AppState {
|
||||
private var earBoostTask: Task<Void, Never>?
|
||||
|
||||
init(preview: Bool = false) {
|
||||
self.isPreview = preview
|
||||
self.isPreview = preview || ProcessInfo.processInfo.isRunningTests
|
||||
let onboardingSeen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
|
||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
self.launchAtLogin = false
|
||||
@@ -302,8 +294,6 @@ final class AppState {
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
|
||||
if !self.isPreview {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
let current = await LaunchAgentManager.status()
|
||||
@@ -604,7 +594,6 @@ extension AppState {
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
state.attachExistingGatewayOnly = false
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -623,10 +612,6 @@ enum AppStateStore {
|
||||
static var canvasEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
84
apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift
Normal file
84
apps/macos/Sources/Clawdbot/CLIInstallPrompter.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
final class CLIInstallPrompter {
|
||||
static let shared = CLIInstallPrompter()
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "cli.prompt")
|
||||
private var isPrompting = false
|
||||
|
||||
func checkAndPromptIfNeeded(reason: String) {
|
||||
guard self.shouldPrompt() else { return }
|
||||
guard let version = Self.appVersion() else { return }
|
||||
self.isPrompting = true
|
||||
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Install Clawdbot CLI?"
|
||||
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
|
||||
alert.addButton(withTitle: "Install CLI")
|
||||
alert.addButton(withTitle: "Not now")
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
let response = alert.runModal()
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
Task { await self.installCLI() }
|
||||
case .alertThirdButtonReturn:
|
||||
self.openSettings(tab: .general)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||
self.isPrompting = false
|
||||
}
|
||||
|
||||
private func shouldPrompt() -> Bool {
|
||||
guard !self.isPrompting else { return false }
|
||||
guard AppStateStore.shared.onboardingSeen else { return false }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return false }
|
||||
guard CLIInstaller.installedLocation() == nil else { return false }
|
||||
guard let version = Self.appVersion() else { return false }
|
||||
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
return lastPrompt != version
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
let status = StatusBox()
|
||||
await CLIInstaller.install { message in
|
||||
await status.set(message)
|
||||
}
|
||||
if let message = await status.get() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "CLI install finished"
|
||||
alert.informativeText = message
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
SettingsWindowOpener.shared.open()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
private static func appVersion() -> String? {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
}
|
||||
|
||||
private actor StatusBox {
|
||||
private var value: String?
|
||||
|
||||
func set(_ value: String) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func get() -> String? {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,16 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
private static func embeddedHelperURL() -> URL {
|
||||
Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/Relay/clawdbot")
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: cliHelperSearchPaths,
|
||||
embeddedHelper: self.embeddedHelperURL(),
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
fileManager: .default)
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
embeddedHelper: URL,
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
let embedded = embeddedHelper.resolvingSymlinksInPath()
|
||||
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("clawdbot").path
|
||||
var isDirectory: ObjCBool = false
|
||||
@@ -32,10 +24,7 @@ enum CLIInstaller {
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
let resolved = URL(fileURLWithPath: candidate).resolvingSymlinksInPath()
|
||||
if resolved == embedded {
|
||||
return candidate
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -45,58 +34,70 @@ enum CLIInstaller {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||
let helper = self.embeddedHelperURL()
|
||||
guard FileManager.default.isExecutableFile(atPath: helper.path) else {
|
||||
await statusHandler(
|
||||
"Embedded CLI missing in bundle; repackage via scripts/package-mac-app.sh " +
|
||||
"(or restart-mac.sh without SKIP_GATEWAY_PACKAGE=1).")
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
|
||||
|
||||
if response.success {
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
let installedVersion = parsed.last { $0.event == "done" }?.version
|
||||
let summary = installedVersion.map { "Installed clawdbot \($0)." } ?? "Installed clawdbot."
|
||||
await statusHandler(summary)
|
||||
return
|
||||
}
|
||||
|
||||
let targets = cliHelperSearchPaths.map { "\($0)/clawdbot" }
|
||||
let result = await self.privilegedSymlink(source: helper.path, targets: targets)
|
||||
await statusHandler(result)
|
||||
}
|
||||
|
||||
private static func privilegedSymlink(source: String, targets: [String]) async -> String {
|
||||
let escapedSource = self.shellEscape(source)
|
||||
let targetList = targets.map(self.shellEscape).joined(separator: " ")
|
||||
let cmds = [
|
||||
"mkdir -p /usr/local/bin /opt/homebrew/bin",
|
||||
targets.map { "ln -sf \(escapedSource) \($0)" }.joined(separator: "; "),
|
||||
].joined(separator: "; ")
|
||||
|
||||
let script = """
|
||||
do shell script "\(cmds)" with administrator privileges
|
||||
"""
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
proc.arguments = ["-e", script]
|
||||
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = pipe
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if proc.terminationStatus == 0 {
|
||||
return output.isEmpty ? "CLI helper linked into \(targetList)" : output
|
||||
}
|
||||
if output.lowercased().contains("user canceled") {
|
||||
return "Install canceled"
|
||||
}
|
||||
return "Failed to install CLI helper: \(output)"
|
||||
} catch {
|
||||
return "Failed to run installer: \(error.localizedDescription)"
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
if let error = parsed.last(where: { $0.event == "error" })?.message {
|
||||
await statusHandler("Install failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallback = response.errorMessage ?? "install failed"
|
||||
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
|
||||
}
|
||||
|
||||
private static func shellEscape(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
private static func installPrefix() -> String {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.path
|
||||
}
|
||||
|
||||
private static func installScriptCommand(version: String, prefix: String) -> [String] {
|
||||
let escapedVersion = self.shellEscape(version)
|
||||
let escapedPrefix = self.shellEscape(prefix)
|
||||
let script = """
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | \
|
||||
bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion)
|
||||
"""
|
||||
return ["/bin/bash", "-lc", script]
|
||||
}
|
||||
|
||||
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
|
||||
let decoder = JSONDecoder()
|
||||
let lines = output
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { String($0) }
|
||||
var events: [InstallEvent] = []
|
||||
for line in lines {
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
if let event = try? decoder.decode(InstallEvent.self, from: data) {
|
||||
events.append(event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
private static func shellEscape(_ raw: String) -> String {
|
||||
"'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstallEvent: Decodable {
|
||||
let event: String
|
||||
let version: String?
|
||||
let message: String?
|
||||
}
|
||||
|
||||
@@ -84,12 +84,30 @@ enum CommandResolver {
|
||||
"/bin",
|
||||
]
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
||||
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||
if !clawdbotPaths.isEmpty {
|
||||
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||
}
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + clawdbotPaths.count)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func clawdbotManagedPaths(home: URL) -> [String] {
|
||||
let base = home.appendingPathComponent(".clawdbot")
|
||||
let bin = base.appendingPathComponent("bin")
|
||||
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||
var paths: [String] = []
|
||||
if FileManager.default.fileExists(atPath: bin.path) {
|
||||
paths.append(bin.path)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
||||
paths.append(nodeBin.path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||
var bins: [String] = []
|
||||
|
||||
@@ -387,10 +405,6 @@ enum CommandResolver {
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
|
||||
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
|
||||
self.connectionSettings(defaults: defaults).mode == .remote
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -69,7 +70,9 @@ struct ConfigSettings: View {
|
||||
self.allowAutosave = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
@@ -106,8 +109,7 @@ struct ConfigSettings: View {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelPickerField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
@@ -116,37 +118,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)
|
||||
@@ -384,7 +463,9 @@ struct ConfigSettings: View {
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadConfig() async {
|
||||
let parsed = await ConfigStore.load()
|
||||
let agents = parsed["agents"] as? [String: Any]
|
||||
@@ -403,10 +484,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 +538,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 +550,6 @@ struct ConfigSettings: View {
|
||||
|
||||
let draft = ConfigDraft(
|
||||
configModel: configModel,
|
||||
customModel: customModel,
|
||||
heartbeatMinutes: heartbeatMinutes,
|
||||
heartbeatBody: heartbeatBody,
|
||||
browserEnabled: browserEnabled,
|
||||
@@ -498,8 +575,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] ?? [:]
|
||||
@@ -567,7 +643,9 @@ struct ConfigSettings: View {
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private var browserColor: Color {
|
||||
let raw = self.browserColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hex = raw.hasPrefix("#") ? String(raw.dropFirst()) : raw
|
||||
@@ -664,7 +742,9 @@ struct ConfigSettings: View {
|
||||
if host == "::1" { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private func loadModels() async {
|
||||
guard !self.modelsLoading else { return }
|
||||
self.modelsLoading = true
|
||||
@@ -678,23 +758,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 +775,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 +908,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()
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ final class ConnectionModeCoordinator {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: paused,
|
||||
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
|
||||
paused: paused)
|
||||
{
|
||||
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
extension ConnectionsSettings {
|
||||
private func providerStatus<T: Decodable>(
|
||||
_ id: String,
|
||||
as type: T.Type) -> T?
|
||||
{
|
||||
self.store.snapshot?.decodeProvider(id, as: type)
|
||||
}
|
||||
|
||||
var whatsAppTint: Color {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return .secondary }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if !status.linked { return .red }
|
||||
if status.lastError != nil { return .orange }
|
||||
@@ -12,7 +20,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramTint: Color {
|
||||
guard let status = self.store.snapshot?.telegram else { return .secondary }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -21,7 +30,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordTint: Color {
|
||||
guard let status = self.store.snapshot?.discord else { return .secondary }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -30,7 +40,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.store.snapshot?.signal else { return .secondary }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -39,7 +50,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageTint: Color {
|
||||
guard let status = self.store.snapshot?.imessage else { return .secondary }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return .secondary }
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
@@ -48,7 +60,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var whatsAppSummary: String {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return "Checking…" }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.linked { return "Not linked" }
|
||||
if status.connected { return "Connected" }
|
||||
if status.running { return "Running" }
|
||||
@@ -56,35 +69,40 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramSummary: String {
|
||||
guard let status = self.store.snapshot?.telegram else { return "Checking…" }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var discordSummary: String {
|
||||
guard let status = self.store.snapshot?.discord else { return "Checking…" }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.store.snapshot?.signal else { return "Checking…" }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var imessageSummary: String {
|
||||
guard let status = self.store.snapshot?.imessage else { return "Checking…" }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return "Checking…" }
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var whatsAppDetails: String? {
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let e164 = status.`self`?.e164 ?? status.`self`?.jid {
|
||||
lines.append("Linked as \(e164)")
|
||||
@@ -114,7 +132,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var telegramDetails: String? {
|
||||
guard let status = self.store.snapshot?.telegram else { return nil }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -145,7 +164,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var discordDetails: String? {
|
||||
guard let status = self.store.snapshot?.discord else { return nil }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
@@ -173,7 +193,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
guard let status = self.store.snapshot?.signal else { return nil }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
if let probe = status.probe {
|
||||
@@ -199,7 +220,8 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var imessageDetails: String? {
|
||||
guard let status = self.store.snapshot?.imessage else { return nil }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
if let cliPath = status.cliPath, !cliPath.isEmpty {
|
||||
lines.append("CLI: \(cliPath)")
|
||||
@@ -221,11 +243,11 @@ extension ConnectionsSettings {
|
||||
}
|
||||
|
||||
var isTelegramTokenLocked: Bool {
|
||||
self.store.snapshot?.telegram.tokenSource == "env"
|
||||
self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var isDiscordTokenLocked: Bool {
|
||||
self.store.snapshot?.discord?.tokenSource == "env"
|
||||
self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?.tokenSource == "env"
|
||||
}
|
||||
|
||||
var orderedProviders: [ConnectionProvider] {
|
||||
@@ -258,19 +280,24 @@ extension ConnectionsSettings {
|
||||
func providerEnabled(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.linked || status.running
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.configured || status.running
|
||||
}
|
||||
}
|
||||
@@ -344,35 +371,48 @@ extension ConnectionsSettings {
|
||||
func providerLastCheck(_ provider: ConnectionProvider) -> Date? {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return nil }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return nil }
|
||||
return self.date(fromMs: status.lastEventAt ?? status.lastMessageAt ?? status.lastConnectedAt)
|
||||
case .telegram:
|
||||
return self.date(fromMs: self.store.snapshot?.telegram.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .discord:
|
||||
return self.date(fromMs: self.store.snapshot?.discord?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)?
|
||||
.lastProbeAt)
|
||||
case .signal:
|
||||
return self.date(fromMs: self.store.snapshot?.signal?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||
case .imessage:
|
||||
return self.date(fromMs: self.store.snapshot?.imessage?.lastProbeAt)
|
||||
return self
|
||||
.date(fromMs: self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)?
|
||||
.lastProbeAt)
|
||||
}
|
||||
}
|
||||
|
||||
func providerHasError(_ provider: ConnectionProvider) -> Bool {
|
||||
switch provider {
|
||||
case .whatsapp:
|
||||
guard let status = self.store.snapshot?.whatsapp else { return false }
|
||||
guard let status = self.providerStatus("whatsapp", as: ProvidersStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.lastDisconnect?.loggedOut == true
|
||||
case .telegram:
|
||||
guard let status = self.store.snapshot?.telegram else { return false }
|
||||
guard let status = self.providerStatus("telegram", as: ProvidersStatusSnapshot.TelegramStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .discord:
|
||||
guard let status = self.store.snapshot?.discord else { return false }
|
||||
guard let status = self.providerStatus("discord", as: ProvidersStatusSnapshot.DiscordStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .signal:
|
||||
guard let status = self.store.snapshot?.signal else { return false }
|
||||
guard let status = self.providerStatus("signal", as: ProvidersStatusSnapshot.SignalStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
case .imessage:
|
||||
guard let status = self.store.snapshot?.imessage else { return false }
|
||||
guard let status = self.providerStatus("imessage", as: ProvidersStatusSnapshot.IMessageStatus.self)
|
||||
else { return false }
|
||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,12 @@ extension ConnectionsStore {
|
||||
self.whatsappBusy = true
|
||||
defer { self.whatsappBusy = false }
|
||||
do {
|
||||
let result: WhatsAppLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .webLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("whatsapp"),
|
||||
]
|
||||
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .providersLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
self.whatsappLoginMessage = result.cleared
|
||||
? "Logged out and cleared credentials."
|
||||
@@ -119,9 +122,12 @@ extension ConnectionsStore {
|
||||
self.telegramBusy = true
|
||||
defer { self.telegramBusy = false }
|
||||
do {
|
||||
let result: TelegramLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .telegramLogout,
|
||||
params: nil,
|
||||
let params: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("telegram"),
|
||||
]
|
||||
let result: ProviderLogoutResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .providersLogout,
|
||||
params: params,
|
||||
timeoutMs: 15000)
|
||||
if result.envToken == true {
|
||||
self.configStatus = "Telegram token still set via env; config cleared."
|
||||
@@ -148,11 +154,9 @@ private struct WhatsAppLoginWaitResult: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct WhatsAppLogoutResult: Codable {
|
||||
let cleared: Bool
|
||||
}
|
||||
|
||||
private struct TelegramLogoutResult: Codable {
|
||||
private struct ProviderLogoutResult: Codable {
|
||||
let provider: String?
|
||||
let accountId: String?
|
||||
let cleared: Bool
|
||||
let envToken: Bool?
|
||||
}
|
||||
|
||||
@@ -121,12 +121,54 @@ struct ProvidersStatusSnapshot: Codable {
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct ProviderAccountSnapshot: Codable {
|
||||
let accountId: String
|
||||
let name: String?
|
||||
let enabled: Bool?
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let running: Bool?
|
||||
let connected: Bool?
|
||||
let reconnectAttempts: Int?
|
||||
let lastConnectedAt: Double?
|
||||
let lastError: String?
|
||||
let lastStartAt: Double?
|
||||
let lastStopAt: Double?
|
||||
let lastInboundAt: Double?
|
||||
let lastOutboundAt: Double?
|
||||
let lastProbeAt: Double?
|
||||
let mode: String?
|
||||
let dmPolicy: String?
|
||||
let allowFrom: [String]?
|
||||
let tokenSource: String?
|
||||
let botTokenSource: String?
|
||||
let appTokenSource: String?
|
||||
let baseUrl: String?
|
||||
let allowUnmentionedGroups: Bool?
|
||||
let cliPath: String?
|
||||
let dbPath: String?
|
||||
let port: Int?
|
||||
let probe: AnyCodable?
|
||||
let audit: AnyCodable?
|
||||
let application: AnyCodable?
|
||||
}
|
||||
|
||||
let ts: Double
|
||||
let whatsapp: WhatsAppStatus
|
||||
let telegram: TelegramStatus
|
||||
let discord: DiscordStatus?
|
||||
let signal: SignalStatus?
|
||||
let imessage: IMessageStatus?
|
||||
let providerOrder: [String]
|
||||
let providerLabels: [String: String]
|
||||
let providers: [String: AnyCodable]
|
||||
let providerAccounts: [String: [ProviderAccountSnapshot]]
|
||||
let providerDefaultAccountId: [String: String]
|
||||
|
||||
func decodeProvider<T: Decodable>(_ id: String, as type: T.Type) -> T? {
|
||||
guard let value = self.providers[id] else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
return try JSONDecoder().decode(type, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSnapshot: Codable {
|
||||
|
||||
@@ -32,9 +32,8 @@ let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "clawdbot.deepLinkKey"
|
||||
let modelCatalogPathKey = "clawdbot.modelCatalogPath"
|
||||
let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
|
||||
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
|
||||
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion"
|
||||
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
|
||||
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "clawdbot.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -211,12 +212,6 @@ final class ControlChannel {
|
||||
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
|
||||
case .cannotFindHost, .cannotConnectToHost:
|
||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||
if AppStateStore.attachExistingGatewayOnly, !isRemote {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
|
||||
Disable it in Debug Settings or start a gateway on that port.
|
||||
"""
|
||||
}
|
||||
if isRemote {
|
||||
return """
|
||||
Cannot reach gateway at localhost:\(port).
|
||||
|
||||
@@ -13,7 +13,9 @@ extension CronJobEditor {
|
||||
guard let job else { return }
|
||||
self.name = job.name
|
||||
self.description = job.description ?? ""
|
||||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.deleteAfterRun = job.deleteAfterRun ?? false
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
@@ -59,18 +61,60 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = try self.requireName()
|
||||
let description = self.trimmed(self.description)
|
||||
let agentId = self.trimmed(self.agentId)
|
||||
let schedule = try self.buildSchedule()
|
||||
let payload = try self.buildSelectedPayload()
|
||||
|
||||
try self.validateSessionTarget(payload)
|
||||
try self.validatePayloadRequiredFields(payload)
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
self.applyDeleteAfterRun(to: &root)
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
if !agentId.isEmpty {
|
||||
root["agentId"] = agentId
|
||||
} else if self.job?.agentId != nil {
|
||||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func requireName() throws -> String {
|
||||
let name = self.trimmed(self.name)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
return name
|
||||
}
|
||||
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -78,34 +122,35 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
return ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expr = self.trimmed(self.cronExpr)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let tz = self.trimmed(self.cronTz)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
return ["kind": "cron", "expr": expr]
|
||||
}
|
||||
return ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}
|
||||
|
||||
func validateSessionTarget(_ payload: [String: Any]) throws {
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -122,7 +167,9 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
}
|
||||
|
||||
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
@@ -130,7 +177,8 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
}
|
||||
if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -138,25 +186,14 @@ extension CronJobEditor {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||
if self.scheduleKind == .at {
|
||||
root["deleteAfterRun"] = self.deleteAfterRun
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildAgentTurnPayload() -> [String: Any] {
|
||||
|
||||
@@ -3,6 +3,7 @@ extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.agentId = "ops"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
|
||||
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Agent ID")
|
||||
TextField("Optional (default agent)", text: self.$agentId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$enabled)
|
||||
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Auto-delete")
|
||||
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
case .every:
|
||||
GridRow {
|
||||
self.gridLabel("Every")
|
||||
|
||||
@@ -145,9 +145,11 @@ struct CronJobState: Codable, Equatable {
|
||||
|
||||
struct CronJob: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let agentId: String?
|
||||
var name: String
|
||||
var description: String?
|
||||
var enabled: Bool
|
||||
var deleteAfterRun: Bool?
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
|
||||
@@ -20,6 +20,9 @@ extension CronSettings {
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
@@ -91,9 +94,15 @@ extension CronSettings {
|
||||
func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
if case .at = job.schedule, job.deleteAfterRun == true {
|
||||
LabeledContent("Auto-delete") { Text("after success") }
|
||||
}
|
||||
if let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
|
||||
@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
@@ -59,9 +61,11 @@ extension CronSettings {
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -108,7 +107,7 @@ struct DebugSettings: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("CLI helper")
|
||||
self.gridLabel("CLI")
|
||||
let loc = CLIInstaller.installedLocation()
|
||||
Text(loc ?? "missing")
|
||||
.font(.caption.monospaced())
|
||||
@@ -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
|
||||
}
|
||||
|
||||
private func configURL() -> URL {
|
||||
|
||||
@@ -7,9 +7,8 @@ enum GatewayAutostartPolicy {
|
||||
|
||||
static func shouldEnsureLaunchAgent(
|
||||
mode: AppState.ConnectionMode,
|
||||
paused: Bool,
|
||||
attachExistingOnly: Bool) -> Bool
|
||||
paused: Bool) -> Bool
|
||||
{
|
||||
self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly
|
||||
self.shouldStartGateway(mode: mode, paused: paused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
@@ -75,6 +76,7 @@ actor GatewayChannelActor {
|
||||
private var tickIntervalMs: Double = 30000
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
@@ -163,7 +165,15 @@ actor GatewayChannelActor {
|
||||
self.task = self.session.makeWebSocketTask(url: self.url)
|
||||
self.task?.resume()
|
||||
do {
|
||||
try await self.sendConnect()
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.connectTimeoutSeconds,
|
||||
onTimeout: {
|
||||
NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "connect timed out"])
|
||||
},
|
||||
operation: { try await self.sendConnect() })
|
||||
} catch {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
@@ -192,15 +202,17 @@ actor GatewayChannelActor {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientName = InstanceIdentity.displayName
|
||||
let clientDisplayName = InstanceIdentity.displayName
|
||||
let clientId = "clawdbot-macos"
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
"name": ProtoAnyCodable(clientName),
|
||||
"id": ProtoAnyCodable(clientId),
|
||||
"displayName": ProtoAnyCodable(clientDisplayName),
|
||||
"version": ProtoAnyCodable(
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
|
||||
"platform": ProtoAnyCodable(platform),
|
||||
"mode": ProtoAnyCodable("app"),
|
||||
"mode": ProtoAnyCodable("ui"),
|
||||
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
|
||||
]
|
||||
client["deviceFamily"] = ProtoAnyCodable("Mac")
|
||||
@@ -379,7 +391,11 @@ actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data {
|
||||
func request(
|
||||
method: String,
|
||||
params: [String: ClawdbotProtocol.AnyCodable]?,
|
||||
timeoutMs: Double? = nil) async throws -> Data
|
||||
{
|
||||
do {
|
||||
try await self.connect()
|
||||
} catch {
|
||||
@@ -430,8 +446,8 @@ actor GatewayChannelActor {
|
||||
if res.ok == false {
|
||||
let code = res.error?["code"]?.value as? String
|
||||
let msg = res.error?["message"]?.value as? String
|
||||
let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = AnyCodable(pair.value.value)
|
||||
let details: [String: ClawdbotProtocol.AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in
|
||||
acc[pair.key] = ClawdbotProtocol.AnyCodable(pair.value.value)
|
||||
}
|
||||
throw GatewayResponseError(method: method, code: code, message: msg, details: details)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ enum GatewayAgentProvider: String, Codable, CaseIterable, Sendable {
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
case msteams
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
@@ -61,8 +62,7 @@ actor GatewayConnection {
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
case webLogout = "web.logout"
|
||||
case telegramLogout = "telegram.logout"
|
||||
case providersLogout = "providers.logout"
|
||||
case modelsList = "models.list"
|
||||
case chatHistory = "chat.history"
|
||||
case chatSend = "chat.send"
|
||||
|
||||
@@ -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,13 @@ 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 +239,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 +251,76 @@ 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 +341,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
|
||||
|
||||
@@ -65,12 +65,6 @@ enum GatewayEnvironment {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.env")
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
|
||||
static func bundledGatewayExecutable() -> String? {
|
||||
guard let res = Bundle.main.resourceURL else { return nil }
|
||||
let path = res.appendingPathComponent("Relay/clawdbot").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)
|
||||
@@ -105,28 +99,6 @@ enum GatewayEnvironment {
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
|
||||
if let bundled = self.bundledGatewayExecutable() {
|
||||
let installed = self.readGatewayVersion(binary: bundled)
|
||||
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."
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
message: message)
|
||||
}
|
||||
let gatewayVersionText = installed?.description ?? "unknown"
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .ok,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
message: "Bundled gateway \(gatewayVersionText) (bun)")
|
||||
}
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
|
||||
@@ -147,7 +119,7 @@ enum GatewayEnvironment {
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: "clawdbot CLI not found in PATH; install the global package.")
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||
@@ -197,7 +169,6 @@ enum GatewayEnvironment {
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
let status = self.check()
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
let bundled = self.bundledGatewayExecutable()
|
||||
let runtime = RuntimeLocator.resolve(searchPaths: CommandResolver.preferredPaths())
|
||||
|
||||
guard case .ok = status.kind else {
|
||||
@@ -205,20 +176,17 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
let port = self.gatewayPort()
|
||||
if let bundled {
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [bundled, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
if let gatewayBin {
|
||||
let cmd = [gatewayBin, "gateway", "--port", "\(port)"]
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
if let entry = projectEntrypoint,
|
||||
case let .success(resolvedRuntime) = runtime
|
||||
{
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)"]
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
return GatewayCommandResolution(status: status, command: cmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,18 @@ 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 enum GatewayProgramArgumentsError: LocalizedError {
|
||||
case message(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .message(message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var plistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
@@ -15,32 +27,46 @@ enum GatewayLaunchAgentManager {
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist")
|
||||
}
|
||||
|
||||
private static func gatewayExecutablePath(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay/clawdbot"
|
||||
}
|
||||
|
||||
private static func relayDir(bundlePath: String) -> String {
|
||||
"\(bundlePath)/Contents/Resources/Relay"
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
private static func gatewayProgramArguments(
|
||||
port: Int,
|
||||
bind: String) -> Result<[String], GatewayProgramArgumentsError>
|
||||
{
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
#if DEBUG
|
||||
if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
return .success([localBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution()
|
||||
{
|
||||
return CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot) {
|
||||
switch CommandResolver.runtimeResolution() {
|
||||
case let .success(runtime):
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
if let gatewayBin = CommandResolver.clawdbotExecutable(searchPaths: searchPaths) {
|
||||
return .success([gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
|
||||
if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot),
|
||||
case let .success(runtime) = CommandResolver.runtimeResolution(searchPaths: searchPaths)
|
||||
{
|
||||
let cmd = CommandResolver.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: "gateway-daemon",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
return .success(cmd)
|
||||
}
|
||||
|
||||
return .failure(.message("clawdbot CLI not found in PATH; install the CLI."))
|
||||
}
|
||||
|
||||
static func isLoaded() async -> Bool {
|
||||
@@ -50,14 +76,14 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
_ = bundlePath
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
}
|
||||
if enabled {
|
||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
|
||||
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
|
||||
}
|
||||
|
||||
let desiredBind = self.preferredGatewayBind() ?? "loopback"
|
||||
let desiredToken = self.preferredGatewayToken()
|
||||
@@ -67,22 +93,30 @@ enum GatewayLaunchAgentManager {
|
||||
bind: desiredBind,
|
||||
token: desiredToken,
|
||||
password: desiredPassword)
|
||||
let programArgumentsResult = self.gatewayProgramArguments(port: port, bind: desiredBind)
|
||||
guard case let .success(programArguments) = programArgumentsResult else {
|
||||
if case let .failure(error) = programArgumentsResult {
|
||||
let message = error.localizedDescription
|
||||
self.logger.error("launchd enable failed: \(message)")
|
||||
return message
|
||||
}
|
||||
return "Failed to resolve gateway command."
|
||||
}
|
||||
|
||||
// If launchd already loaded the job (common on login), avoid `bootout` unless we must
|
||||
// change the config. `bootout` can kill a just-started gateway and cause attach loops.
|
||||
let loaded = await self.isLoaded()
|
||||
if loaded,
|
||||
let existing = self.readPlistConfig(),
|
||||
existing.matches(desiredConfig)
|
||||
{
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
if loaded {
|
||||
if let existing = self.readPlistConfig(), existing.matches(desiredConfig) {
|
||||
self.logger.info("launchd job already loaded with desired config; skipping bootout")
|
||||
await self.ensureEnabled()
|
||||
_ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
|
||||
self.writePlist(bundlePath: bundlePath, port: port)
|
||||
self.writePlist(programArguments: programArguments)
|
||||
|
||||
await self.ensureEnabled()
|
||||
if loaded {
|
||||
@@ -111,19 +145,13 @@ enum GatewayLaunchAgentManager {
|
||||
_ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
|
||||
}
|
||||
|
||||
private static func writePlist(bundlePath: String, port: Int) {
|
||||
let relayDir = self.relayDir(bundlePath: bundlePath)
|
||||
let preferredPath = ([relayDir] + CommandResolver.preferredPaths())
|
||||
.joined(separator: ":")
|
||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||
let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind)
|
||||
private static func writePlist(programArguments: [String]) {
|
||||
let preferredPath = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let token = self.preferredGatewayToken()
|
||||
let password = self.preferredGatewayPassword()
|
||||
var envEntries = """
|
||||
<key>PATH</key>
|
||||
<string>\(preferredPath)</string>
|
||||
<key>CLAWDBOT_IMAGE_BACKEND</key>
|
||||
<string>sips</string>
|
||||
"""
|
||||
if let token {
|
||||
let escapedToken = self.escapePlistValue(token)
|
||||
@@ -204,7 +232,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,16 +329,16 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayLaunchAgentManager {
|
||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||
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 {
|
||||
self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testRelayDir(bundlePath: String) -> String {
|
||||
self.relayDir(bundlePath: bundlePath)
|
||||
}
|
||||
|
||||
static func _testPreferredGatewayBind() -> String? {
|
||||
self.preferredGatewayBind()
|
||||
}
|
||||
|
||||
@@ -69,7 +69,6 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
guard !AppStateStore.attachExistingGatewayOnly else { return }
|
||||
let enabled = await GatewayLaunchAgentManager.isLoaded()
|
||||
guard !enabled else { return }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
@@ -97,15 +96,6 @@ final class GatewayProcessManager {
|
||||
if await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
// Respect debug toggle: only attach, never spawn, when enabled.
|
||||
if AppStateStore.attachExistingGatewayOnly {
|
||||
await MainActor.run {
|
||||
self.status = .failed("Attach-only enabled; no gateway to attach")
|
||||
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
|
||||
self.logger.warning("gateway attach-only enabled; not spawning")
|
||||
}
|
||||
return
|
||||
}
|
||||
await self.enableLaunchdGateway()
|
||||
}
|
||||
}
|
||||
@@ -221,9 +211,21 @@ final class GatewayProcessManager {
|
||||
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
|
||||
let instanceText = instance ?? "pid unknown"
|
||||
if let snap {
|
||||
let linked = snap.web.linked ? "linked" : "not linked"
|
||||
let authAge = snap.web.authAgeMs.flatMap(msToAge) ?? "unknown age"
|
||||
return "port \(port), \(linked), auth \(authAge), \(instanceText)"
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||
let authAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
|
||||
let label =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"provider"
|
||||
let linkText = linked ? "linked" : "not linked"
|
||||
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
|
||||
}
|
||||
return "port \(port), health probe succeeded, \(instanceText)"
|
||||
}
|
||||
@@ -239,7 +241,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 {
|
||||
@@ -330,7 +349,7 @@ struct GeneralSettings: View {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI helper" : "Install CLI helper"
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
@@ -369,7 +388,7 @@ struct GeneralSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Symlink \"clawdbot\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
|
||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
@@ -437,10 +456,8 @@ struct GeneralSettings: View {
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
await MainActor.run {
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +496,19 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
if let snap = snapshot {
|
||||
Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))")
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"Link provider"
|
||||
let linkAge = linkId.flatMap { snap.providers[$0]?.authAgeMs }
|
||||
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
|
||||
|
||||
@@ -4,35 +4,29 @@ import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
struct Telegram: Codable, Sendable {
|
||||
struct ProviderSummary: Codable, Sendable {
|
||||
struct Probe: Codable, Sendable {
|
||||
struct Bot: Codable, Sendable {
|
||||
let id: Int?
|
||||
let username: String?
|
||||
}
|
||||
|
||||
let ok: Bool
|
||||
struct Webhook: Codable, Sendable {
|
||||
let url: String?
|
||||
}
|
||||
|
||||
let ok: Bool?
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
let bot: Bot?
|
||||
let webhook: Webhook?
|
||||
}
|
||||
|
||||
let configured: Bool
|
||||
let probe: Probe?
|
||||
}
|
||||
|
||||
struct Web: Codable, Sendable {
|
||||
struct Connect: Codable, Sendable {
|
||||
let ok: Bool
|
||||
let status: Int?
|
||||
let error: String?
|
||||
let elapsedMs: Double?
|
||||
}
|
||||
|
||||
let linked: Bool
|
||||
let configured: Bool?
|
||||
let linked: Bool?
|
||||
let authAgeMs: Double?
|
||||
let connect: Connect?
|
||||
let probe: Probe?
|
||||
let lastProbeAt: Double?
|
||||
}
|
||||
|
||||
struct SessionInfo: Codable, Sendable {
|
||||
@@ -50,8 +44,9 @@ struct HealthSnapshot: Codable, Sendable {
|
||||
let ok: Bool?
|
||||
let ts: Double
|
||||
let durationMs: Double
|
||||
let web: Web
|
||||
let telegram: Telegram?
|
||||
let providers: [String: ProviderSummary]
|
||||
let providerOrder: [String]?
|
||||
let providerLabels: [String: String]?
|
||||
let heartbeatSeconds: Int?
|
||||
let sessions: Sessions
|
||||
}
|
||||
@@ -94,6 +89,13 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only escape hatch: the HealthStore is a process-wide singleton but
|
||||
// state derivation is pure from `snapshot` + `lastError`.
|
||||
func __setSnapshotForTest(_ snapshot: HealthSnapshot?, lastError: String? = nil) {
|
||||
self.snapshot = snapshot
|
||||
self.lastError = lastError
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.loopTask == nil else { return }
|
||||
self.loopTask = Task { [weak self] in
|
||||
@@ -142,10 +144,49 @@ final class HealthStore {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool {
|
||||
guard let tg = snap.telegram, tg.configured else { return false }
|
||||
private static func isProviderHealthy(_ summary: HealthSnapshot.ProviderSummary) -> Bool {
|
||||
guard summary.configured == true else { return false }
|
||||
// If probe is missing, treat it as "configured but unknown health" (not a hard fail).
|
||||
return tg.probe?.ok ?? true
|
||||
return summary.probe?.ok ?? true
|
||||
}
|
||||
|
||||
private static func describeProbeFailure(_ probe: HealthSnapshot.ProviderSummary.Probe) -> String {
|
||||
let elapsed = probe.elapsedMs.map { "\(Int($0))ms" }
|
||||
if let error = probe.error, error.lowercased().contains("timeout") || probe.status == nil {
|
||||
if let elapsed { return "Health check timed out (\(elapsed))" }
|
||||
return "Health check timed out"
|
||||
}
|
||||
let code = probe.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = probe.error?.isEmpty == false ? probe.error! : "health probe failed"
|
||||
if let elapsed { return "\(reason) (\(code), \(elapsed))" }
|
||||
return "\(reason) (\(code))"
|
||||
}
|
||||
|
||||
private func resolveLinkProvider(
|
||||
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||
{
|
||||
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||
for id in order {
|
||||
if let summary = snap.providers[id], summary.linked != nil {
|
||||
return (id: id, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveFallbackProvider(
|
||||
_ snap: HealthSnapshot,
|
||||
excluding id: String?) -> (id: String, summary: HealthSnapshot.ProviderSummary)?
|
||||
{
|
||||
let order = snap.providerOrder ?? Array(snap.providers.keys)
|
||||
for providerId in order {
|
||||
if providerId == id { continue }
|
||||
guard let summary = snap.providers[providerId] else { continue }
|
||||
if Self.isProviderHealthy(summary) {
|
||||
return (id: providerId, summary: summary)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var state: HealthState {
|
||||
@@ -153,13 +194,15 @@ final class HealthStore {
|
||||
return .degraded(error)
|
||||
}
|
||||
guard let snap = self.snapshot else { return .unknown }
|
||||
if !snap.web.linked {
|
||||
// WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red.
|
||||
return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded
|
||||
guard let link = self.resolveLinkProvider(snap) else { return .unknown }
|
||||
if link.summary.linked != true {
|
||||
// Linking is optional if any other provider is healthy; don't paint the whole app red.
|
||||
let fallback = self.resolveFallbackProvider(snap, excluding: link.id)
|
||||
return fallback != nil ? .degraded("Not linked") : .linkingNeeded
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return .degraded(reason)
|
||||
// A provider can be "linked" but still unhealthy (failed probe / cannot connect).
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
return .degraded(Self.describeProbeFailure(probe))
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
@@ -168,19 +211,22 @@ final class HealthStore {
|
||||
if self.isRefreshing { return "Health check running…" }
|
||||
if let error = self.lastError { return "Health check failed: \(error)" }
|
||||
guard let snap = self.snapshot else { return "Health check pending" }
|
||||
if !snap.web.linked {
|
||||
if let tg = snap.telegram, tg.configured {
|
||||
let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded"
|
||||
return "\(tgLabel) · Not linked — run clawdbot login"
|
||||
guard let link = self.resolveLinkProvider(snap) else { return "Health check pending" }
|
||||
if link.summary.linked != true {
|
||||
if let fallback = self.resolveFallbackProvider(snap, excluding: link.id) {
|
||||
let fallbackLabel = snap.providerLabels?[fallback.id] ?? fallback.id.capitalized
|
||||
let fallbackState = (fallback.summary.probe?.ok ?? true) ? "ok" : "degraded"
|
||||
return "\(fallbackLabel) \(fallbackState) · Not linked — run clawdbot login"
|
||||
}
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let code = connect.status.map(String.init) ?? "?"
|
||||
return "Link stale? status \(code)"
|
||||
let auth = link.summary.authAgeMs.map { msToAge($0) } ?? "unknown"
|
||||
if let probe = link.summary.probe, probe.ok == false {
|
||||
let status = probe.status.map(String.init) ?? "?"
|
||||
let suffix = probe.status == nil ? "probe degraded" : "probe degraded · status \(status)"
|
||||
return "linked · auth \(auth) · \(suffix)"
|
||||
}
|
||||
return "linked · auth \(auth) · socket ok"
|
||||
return "linked · auth \(auth)"
|
||||
}
|
||||
|
||||
/// Short, human-friendly detail for the last failure, used in the UI.
|
||||
@@ -201,17 +247,11 @@ final class HealthStore {
|
||||
}
|
||||
|
||||
func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String {
|
||||
if !snap.web.linked {
|
||||
if let link = self.resolveLinkProvider(snap), link.summary.linked != true {
|
||||
return "Not linked — run clawdbot login"
|
||||
}
|
||||
if let connect = snap.web.connect, !connect.ok {
|
||||
let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration"
|
||||
if let err = connect.error, err.lowercased().contains("timeout") || connect.status == nil {
|
||||
return "Health check timed out (\(elapsed))"
|
||||
}
|
||||
let code = connect.status.map { "status \($0)" } ?? "status unknown"
|
||||
let reason = connect.error ?? "connect failed"
|
||||
return "\(reason) (\(code), \(elapsed))"
|
||||
if let link = self.resolveLinkProvider(snap), let probe = link.summary.probe, probe.ok == false {
|
||||
return Self.describeProbeFailure(probe)
|
||||
}
|
||||
if let fallback, !fallback.isEmpty {
|
||||
return fallback
|
||||
|
||||
@@ -242,6 +242,18 @@ final class InstancesStore {
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 8)
|
||||
guard let snap = decodeHealthSnapshot(from: data) else { return }
|
||||
let linkId = snap.providerOrder?.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
}) ?? snap.providers.keys.first(where: {
|
||||
if let summary = snap.providers[$0] { return summary.linked != nil }
|
||||
return false
|
||||
})
|
||||
let linked = linkId.flatMap { snap.providers[$0]?.linked } ?? false
|
||||
let linkLabel =
|
||||
linkId.flatMap { snap.providerLabels?[$0] } ??
|
||||
linkId?.capitalized ??
|
||||
"provider"
|
||||
let entry = InstanceInfo(
|
||||
id: "health-\(snap.ts)",
|
||||
host: "gateway (health)",
|
||||
@@ -253,7 +265,7 @@ final class InstancesStore {
|
||||
lastInputSeconds: nil,
|
||||
mode: "health",
|
||||
reason: "health probe",
|
||||
text: "Health ok · linked=\(snap.web.linked)",
|
||||
text: "Health ok · \(linkLabel) linked=\(linked)",
|
||||
ts: snap.ts)
|
||||
if !self.instances.contains(where: { $0.id == entry.id }) {
|
||||
self.instances.insert(entry, at: 0)
|
||||
|
||||
@@ -29,7 +29,7 @@ enum LogLocator {
|
||||
stdoutLog.path
|
||||
}
|
||||
|
||||
/// Path to use for the embedded Gateway launchd job stdout/err.
|
||||
/// Path to use for the Gateway launchd job stdout/err.
|
||||
static var launchdGatewayLogPath: String {
|
||||
gatewayLog.path
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct ClawdbotApp: App {
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, mode in
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||
}
|
||||
|
||||
Settings {
|
||||
@@ -262,6 +263,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
|
||||
Task { await PeekabooBridgeHostCoordinator.shared.setEnabled(AppStateStore.shared.peekabooBridgeEnabled) }
|
||||
self.scheduleFirstRunOnboardingIfNeeded()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
|
||||
}
|
||||
|
||||
// Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat).
|
||||
if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") {
|
||||
|
||||
@@ -153,6 +153,9 @@ struct MenuContent: View {
|
||||
self.micRefreshTask = nil
|
||||
self.micObserver.stop()
|
||||
}
|
||||
.task { @MainActor in
|
||||
SettingsWindowOpener.shared.register(openSettings: self.openSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionLabel: String {
|
||||
@@ -276,13 +279,18 @@ struct MenuContent: View {
|
||||
Label("Send Test Notification", systemImage: "bell")
|
||||
}
|
||||
Divider()
|
||||
if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly {
|
||||
if self.state.connectionMode == .local {
|
||||
Button {
|
||||
DebugActions.restartGateway()
|
||||
} label: {
|
||||
Label("Restart Gateway", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
Label("Restart Onboarding", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
Button {
|
||||
DebugActions.restartApp()
|
||||
} label: {
|
||||
@@ -296,7 +304,9 @@ struct MenuContent: View {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -99,7 +99,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
return NSRect.zero
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Injection
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
@@ -111,6 +113,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 +136,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 +169,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 +221,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 +283,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 +321,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 +358,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 +394,19 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private func controlChannelStatusText(for state: ControlChannel.ConnectionState) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
"Loading sessions…"
|
||||
case .connecting:
|
||||
"Connecting…"
|
||||
case let .degraded(message):
|
||||
message.nonEmpty ?? "Gateway disconnected"
|
||||
case .disconnected:
|
||||
"Gateway disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
@@ -391,18 +478,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
|
||||
@@ -410,7 +510,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Cache
|
||||
|
||||
private func refreshCache(force: Bool) async {
|
||||
@@ -481,7 +583,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
return "Sessions unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Submenus
|
||||
|
||||
private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu {
|
||||
@@ -813,7 +917,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Width + placement
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
@@ -889,7 +995,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return lhsName < rhsName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuSessionsInjector {
|
||||
// MARK: - Views
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
@@ -938,6 +1046,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()
|
||||
@@ -336,7 +358,7 @@ actor MacNodeBridgeSession {
|
||||
"Node bridge heartbeat timed out; disconnecting " +
|
||||
"(age: \(ageDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.disconnect()
|
||||
await self.handleDisconnect(reason: "ping timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -350,7 +372,7 @@ actor MacNodeBridgeSession {
|
||||
"Node bridge ping send failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.disconnect()
|
||||
await self.handleDisconnect(reason: "ping send failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -369,15 +391,45 @@ actor MacNodeBridgeSession {
|
||||
"Node bridge connection failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.disconnect()
|
||||
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
|
||||
@@ -111,22 +120,26 @@ struct OnboardingView: View {
|
||||
let permissionsPageIndex = 5
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
needsBootstrap: Bool) -> [Int]
|
||||
showOnboardingChat: Bool) -> [Int]
|
||||
{
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||
// and WhatsApp/Telegram setup is optional.
|
||||
needsBootstrap ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
||||
showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
||||
case .unconfigured:
|
||||
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
case .local:
|
||||
needsBootstrap ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
}
|
||||
}
|
||||
|
||||
var showOnboardingChat: Bool {
|
||||
self.state.connectionMode == .local && self.needsBootstrap
|
||||
}
|
||||
|
||||
var pageOrder: [Int] {
|
||||
Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap)
|
||||
Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat)
|
||||
}
|
||||
|
||||
var pageCount: Int { self.pageOrder.count }
|
||||
@@ -142,8 +155,8 @@ struct OnboardingView: View {
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let bundlePath = Bundle.main.bundlePath
|
||||
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdbot' /usr/local/bin/clawdbot"
|
||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
return "npm install -g clawdbot@\(version)"
|
||||
}
|
||||
|
||||
struct LocalGatewayProbe: Equatable {
|
||||
@@ -156,7 +169,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,5 +1,7 @@
|
||||
import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
@@ -40,7 +42,9 @@ extension OnboardingView {
|
||||
func openSettings(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
self.openSettings()
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .clawdbotSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
|
||||
func handleBack() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
extension OnboardingView {
|
||||
func maybeKickoffOnboardingChat(for pageIndex: Int) {
|
||||
guard pageIndex == self.onboardingChatPageIndex else { return }
|
||||
guard self.needsBootstrap else { return }
|
||||
guard self.showOnboardingChat else { return }
|
||||
guard !self.didAutoKickoff else { return }
|
||||
self.didAutoKickoff = true
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -54,6 +54,7 @@ extension OnboardingView {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
self.stopAuthMonitoring()
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
}
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
@@ -173,6 +174,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()
|
||||
@@ -95,7 +95,7 @@ extension OnboardingView {
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
await MainActor.run { self.cliStatus = message }
|
||||
self.cliStatus = message
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -488,9 +494,9 @@ extension OnboardingView {
|
||||
|
||||
func cliPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Install the helper CLI")
|
||||
Text("Install the CLI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Optional, but recommended: link `clawdbot` so scripts can reach the local gateway.")
|
||||
Text("Required for local mode: installs `clawdbot` so launchd can run the gateway.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -516,7 +522,7 @@ extension OnboardingView {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy dev link") {
|
||||
Button(self.copied ? "Copied" : "Copy install command") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.disabled(self.installingCLI)
|
||||
@@ -535,8 +541,8 @@ extension OnboardingView {
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
We install into /usr/local/bin and /opt/homebrew/bin.
|
||||
Rerun anytime if you move the build output.
|
||||
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -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",
|
||||
|
||||
@@ -35,6 +35,10 @@ final class OnboardingWizardModel {
|
||||
private(set) var errorMessage: String?
|
||||
var isStarting = false
|
||||
var isSubmitting = false
|
||||
private var lastStartMode: AppState.ConnectionMode?
|
||||
private var lastStartWorkspace: String?
|
||||
private var restartAttempts = 0
|
||||
private let maxRestartAttempts = 1
|
||||
|
||||
var isComplete: Bool { self.status == "done" }
|
||||
var isRunning: Bool { self.status == "running" }
|
||||
@@ -46,6 +50,9 @@ final class OnboardingWizardModel {
|
||||
self.errorMessage = nil
|
||||
self.isStarting = false
|
||||
self.isSubmitting = false
|
||||
self.restartAttempts = 0
|
||||
self.lastStartMode = nil
|
||||
self.lastStartWorkspace = nil
|
||||
}
|
||||
|
||||
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
|
||||
@@ -53,9 +60,18 @@ final class OnboardingWizardModel {
|
||||
guard mode == .local else { return }
|
||||
self.isStarting = true
|
||||
self.errorMessage = nil
|
||||
self.lastStartMode = mode
|
||||
self.lastStartWorkspace = workspace
|
||||
defer { self.isStarting = false }
|
||||
|
||||
do {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."])
|
||||
}
|
||||
var params: [String: AnyCodable] = ["mode": AnyCodable("local")]
|
||||
if let workspace, !workspace.isEmpty {
|
||||
params["workspace"] = AnyCodable(workspace)
|
||||
@@ -89,6 +105,9 @@ final class OnboardingWizardModel {
|
||||
params: params)
|
||||
self.applyNextResult(res)
|
||||
} catch {
|
||||
if self.restartIfSessionLost(error: error) {
|
||||
return
|
||||
}
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -111,30 +130,53 @@ final class OnboardingWizardModel {
|
||||
|
||||
private func applyStartResult(_ res: WizardStartResult) {
|
||||
self.sessionId = res.sessionid
|
||||
self.status = anyCodableStringValue(res.status) ?? (res.done ? "done" : "running")
|
||||
self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running")
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = decodeWizardStep(res.step)
|
||||
if self.currentStep == nil, res.step != nil {
|
||||
onboardingWizardLogger.error("wizard step decode failed")
|
||||
}
|
||||
if res.done { self.currentStep = nil }
|
||||
self.restartAttempts = 0
|
||||
}
|
||||
|
||||
private func applyNextResult(_ res: WizardNextResult) {
|
||||
self.status = anyCodableStringValue(res.status) ?? self.status
|
||||
let status = wizardStatusString(res.status)
|
||||
self.status = status ?? self.status
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = decodeWizardStep(res.step)
|
||||
if self.currentStep == nil, res.step != nil {
|
||||
onboardingWizardLogger.error("wizard step decode failed")
|
||||
}
|
||||
if res.done { self.currentStep = nil }
|
||||
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
||||
|| anyCodableStringValue(res.status) == "error"
|
||||
{
|
||||
if res.done || status == "done" || status == "cancelled" || status == "error" {
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatusResult(_ res: WizardStatusResult) {
|
||||
self.status = anyCodableStringValue(res.status) ?? "unknown"
|
||||
self.status = wizardStatusString(res.status) ?? "unknown"
|
||||
self.errorMessage = res.error
|
||||
self.currentStep = nil
|
||||
self.sessionId = nil
|
||||
}
|
||||
|
||||
private func restartIfSessionLost(error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||
let message = gatewayError.message.lowercased()
|
||||
guard message.contains("wizard not found") || message.contains("wizard not running") else { return false }
|
||||
guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else {
|
||||
return false
|
||||
}
|
||||
self.restartAttempts += 1
|
||||
self.sessionId = nil
|
||||
self.currentStep = nil
|
||||
self.status = nil
|
||||
self.errorMessage = "Wizard session lost. Restarting…"
|
||||
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardStepView: View {
|
||||
@@ -333,98 +375,3 @@ private struct WizardOptionItem: Identifiable {
|
||||
|
||||
var id: Int { self.index }
|
||||
}
|
||||
|
||||
private struct WizardOption {
|
||||
let value: ProtocolAnyCodable?
|
||||
let label: String
|
||||
let hint: String?
|
||||
}
|
||||
|
||||
private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(raw)
|
||||
return try JSONDecoder().decode(WizardStep.self, from: data)
|
||||
} catch {
|
||||
onboardingWizardLogger.error("wizard step decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
|
||||
guard let raw else { return [] }
|
||||
return raw.map { entry in
|
||||
let value = entry["value"]
|
||||
let label = (entry["label"]?.value as? String) ?? ""
|
||||
let hint = entry["hint"]?.value as? String
|
||||
return WizardOption(value: value, label: label, hint: hint)
|
||||
}
|
||||
}
|
||||
|
||||
private func wizardStepType(_ step: WizardStep) -> String {
|
||||
(step.type.value as? String) ?? ""
|
||||
}
|
||||
|
||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as String:
|
||||
string
|
||||
case let int as Int:
|
||||
String(int)
|
||||
case let double as Double:
|
||||
String(double)
|
||||
case let bool as Bool:
|
||||
bool ? "true" : "false"
|
||||
default:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
|
||||
value?.value as? String
|
||||
}
|
||||
|
||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as Bool:
|
||||
bool
|
||||
case let string as String:
|
||||
string.lowercased() == "true"
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [ProtocolAnyCodable]:
|
||||
arr
|
||||
case let arr as [Any]:
|
||||
arr.map { ProtocolAnyCodable($0) }
|
||||
default:
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
l == r
|
||||
case let (l as Int, r as Int):
|
||||
l == r
|
||||
case let (l as Double, r as Double):
|
||||
l == r
|
||||
case let (l as Bool, r as Bool):
|
||||
l == r
|
||||
case let (l as String, r as Int):
|
||||
l == String(r)
|
||||
case let (l as Int, r as String):
|
||||
String(l) == r
|
||||
case let (l as String, r as Double):
|
||||
l == String(r)
|
||||
case let (l as Double, r as String):
|
||||
String(l) == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,74 @@ 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()
|
||||
}
|
||||
|
||||
@@ -344,14 +344,17 @@ actor PortGuardian {
|
||||
|
||||
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||
let cmd = listener.command.lowercased()
|
||||
let expectedCommands = ["node", "clawdbot", "tsx", "pnpm", "bun"]
|
||||
let full = listener.fullCommand.lowercased()
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
|
||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||
return false
|
||||
case .local:
|
||||
return expectedCommands.contains { cmd.contains($0) }
|
||||
if !cmd.contains("clawdbot") { return false }
|
||||
if full.contains("gateway-daemon") { return true }
|
||||
// If args are unavailable, treat a clawdbot listener as expected.
|
||||
return full == cmd
|
||||
case .unconfigured:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ final class RemotePortTunnel {
|
||||
static func create(
|
||||
remotePort: Int,
|
||||
preferredLocalPort: UInt16? = nil,
|
||||
allowRemoteUrlOverride: Bool = true) async throws -> RemotePortTunnel
|
||||
allowRemoteUrlOverride: Bool = true,
|
||||
allowRandomLocalPort: Bool = true) async throws -> RemotePortTunnel
|
||||
{
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
@@ -51,7 +52,9 @@ final class RemotePortTunnel {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not configured"])
|
||||
}
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
let localPort = try await Self.findPort(
|
||||
preferred: preferredLocalPort,
|
||||
allowRandom: allowRandomLocalPort)
|
||||
let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remotePortOverride =
|
||||
allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort()
|
||||
@@ -172,8 +175,16 @@ final class RemotePortTunnel {
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
|
||||
if let preferred, self.portIsFree(preferred) { return preferred }
|
||||
if let preferred, !allowRandom {
|
||||
throw NSError(
|
||||
domain: "RemotePortTunnel",
|
||||
code: 5,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Local port \(preferred) is unavailable",
|
||||
])
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
let queue = DispatchQueue(label: "com.clawdbot.remote.tunnel.port", qos: .utility)
|
||||
|
||||
@@ -7,8 +7,15 @@ actor RemoteTunnelManager {
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "remote-tunnel")
|
||||
private var controlTunnel: RemotePortTunnel?
|
||||
private var restartInFlight = false
|
||||
private var lastRestartAt: Date?
|
||||
private let restartBackoffSeconds: TimeInterval = 2.0
|
||||
|
||||
func controlTunnelPortIfRunning() async -> UInt16? {
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skipping reuse check")
|
||||
return nil
|
||||
}
|
||||
if let tunnel = self.controlTunnel,
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
@@ -18,6 +25,7 @@ actor RemoteTunnelManager {
|
||||
return local
|
||||
}
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
@@ -34,6 +42,11 @@ actor RemoteTunnelManager {
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||
return nil
|
||||
}
|
||||
await self.beginRestart()
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
}
|
||||
return nil
|
||||
@@ -56,12 +69,15 @@ actor RemoteTunnelManager {
|
||||
"identitySet=\(identitySet, privacy: .public)")
|
||||
|
||||
if let local = await self.controlTunnelPortIfRunning() { return local }
|
||||
await self.waitForRestartBackoffIfNeeded()
|
||||
|
||||
let desiredPort = UInt16(GatewayEnvironment.gatewayPort())
|
||||
let tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: GatewayEnvironment.gatewayPort(),
|
||||
preferredLocalPort: desiredPort)
|
||||
preferredLocalPort: desiredPort,
|
||||
allowRandomLocalPort: false)
|
||||
self.controlTunnel = tunnel
|
||||
self.endRestart()
|
||||
let resolvedPort = tunnel.localPort ?? desiredPort
|
||||
self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)")
|
||||
return tunnel.localPort ?? desiredPort
|
||||
@@ -83,6 +99,35 @@ actor RemoteTunnelManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private func beginRestart() async {
|
||||
guard !self.restartInFlight else { return }
|
||||
self.restartInFlight = true
|
||||
self.lastRestartAt = Date()
|
||||
self.logger.info("control tunnel restart started")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.restartBackoffSeconds * 1_000_000_000))
|
||||
await self.endRestart()
|
||||
}
|
||||
}
|
||||
|
||||
private func endRestart() {
|
||||
if self.restartInFlight {
|
||||
self.restartInFlight = false
|
||||
self.logger.info("control tunnel restart finished")
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForRestartBackoffIfNeeded() async {
|
||||
guard let last = self.lastRestartAt else { return }
|
||||
let elapsed = Date().timeIntervalSince(last)
|
||||
let remaining = self.restartBackoffSeconds - elapsed
|
||||
guard remaining > 0 else { return }
|
||||
self.logger.info(
|
||||
"control tunnel restart backoff \(remaining, privacy: .public)s")
|
||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
}
|
||||
|
||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||
let pid = desc.pid
|
||||
self.logger.error(
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.9</string>
|
||||
<string>2026.1.11-4</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260109</string>
|
||||
<string>202601113</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -47,10 +47,14 @@
|
||||
<string>Clawdbot captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdbot can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Clawdbot can share your location when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdbot needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdbot uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
|
||||
@@ -58,7 +58,7 @@ struct SettingsRootView: View {
|
||||
PermissionsSettings(
|
||||
status: self.permissionMonitor.status,
|
||||
refresh: self.refreshPerms,
|
||||
showOnboarding: { OnboardingController.shared.show() })
|
||||
showOnboarding: { DebugActions.restartOnboarding() })
|
||||
.tabItem { Label("Permissions", systemImage: "lock.shield") }
|
||||
.tag(SettingsTab.permissions)
|
||||
|
||||
|
||||
36
apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift
Normal file
36
apps/macos/Sources/Clawdbot/SettingsWindowOpener.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@objc
|
||||
private protocol SettingsWindowMenuActions {
|
||||
@objc(showSettingsWindow:)
|
||||
optional func showSettingsWindow(_ sender: Any?)
|
||||
|
||||
@objc(showPreferencesWindow:)
|
||||
optional func showPreferencesWindow(_ sender: Any?)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SettingsWindowOpener {
|
||||
static let shared = SettingsWindowOpener()
|
||||
|
||||
private var openSettingsAction: OpenSettingsAction?
|
||||
|
||||
func register(openSettings: OpenSettingsAction) {
|
||||
self.openSettingsAction = openSettings
|
||||
}
|
||||
|
||||
func open() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let openSettingsAction {
|
||||
openSettingsAction()
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback path: mimic the built-in Settings menu item action.
|
||||
let didOpen = NSApp.sendAction(#selector(SettingsWindowMenuActions.showSettingsWindow(_:)), to: nil, from: nil)
|
||||
if !didOpen {
|
||||
_ = NSApp.sendAction(#selector(SettingsWindowMenuActions.showPreferencesWindow(_:)), to: nil, from: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 self.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 }
|
||||
}
|
||||
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
150
apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift
Normal file
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Generated by scripts/protocol-gen-swift.ts — do not edit by hand
|
||||
import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 2
|
||||
public let GATEWAY_PROTOCOL_VERSION = 3
|
||||
|
||||
public enum ErrorCode: String, Codable, Sendable {
|
||||
case notLinked = "NOT_LINKED"
|
||||
@@ -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
|
||||
@@ -1115,6 +1119,56 @@ public struct ProvidersStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusResult: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let providerorder: [String]
|
||||
public let providerlabels: [String: AnyCodable]
|
||||
public let providers: [String: AnyCodable]
|
||||
public let provideraccounts: [String: AnyCodable]
|
||||
public let providerdefaultaccountid: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ts: Int,
|
||||
providerorder: [String],
|
||||
providerlabels: [String: AnyCodable],
|
||||
providers: [String: AnyCodable],
|
||||
provideraccounts: [String: AnyCodable],
|
||||
providerdefaultaccountid: [String: AnyCodable]
|
||||
) {
|
||||
self.ts = ts
|
||||
self.providerorder = providerorder
|
||||
self.providerlabels = providerlabels
|
||||
self.providers = providers
|
||||
self.provideraccounts = provideraccounts
|
||||
self.providerdefaultaccountid = providerdefaultaccountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ts
|
||||
case providerorder = "providerOrder"
|
||||
case providerlabels = "providerLabels"
|
||||
case providers
|
||||
case provideraccounts = "providerAccounts"
|
||||
case providerdefaultaccountid = "providerDefaultAccountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersLogoutParams: Codable, Sendable {
|
||||
public let provider: String
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
provider: String,
|
||||
accountid: String?
|
||||
) {
|
||||
self.provider = provider
|
||||
self.accountid = accountid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case provider
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
@@ -1298,9 +1352,11 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let agentid: String?
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
public let schedule: AnyCodable
|
||||
@@ -1312,9 +1368,11 @@ public struct CronJob: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
agentid: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
schedule: AnyCodable,
|
||||
@@ -1325,9 +1383,11 @@ public struct CronJob: Codable, Sendable {
|
||||
state: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
self.schedule = schedule
|
||||
@@ -1339,9 +1399,11 @@ public struct CronJob: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
case schedule
|
||||
@@ -1371,8 +1433,10 @@ public struct CronStatusParams: Codable, Sendable {
|
||||
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
@@ -1381,8 +1445,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
@@ -1390,8 +1456,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
isolation: [String: AnyCodable]?
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
@@ -1400,8 +1468,10 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
@@ -1410,70 +1480,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
patch: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.patch = patch
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case patch
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
id: String
|
||||
) {
|
||||
self.id = id
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
mode: AnyCodable?
|
||||
) {
|
||||
self.id = id
|
||||
self.mode = mode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case mode
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
limit: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.limit = limit
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
@@ -1629,11 +1635,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
|
||||
|
||||
106
apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift
Normal file
106
apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
public struct WizardOption: Sendable {
|
||||
public let value: AnyCodable?
|
||||
public let label: String
|
||||
public let hint: String?
|
||||
|
||||
public init(value: AnyCodable?, label: String, hint: String?) {
|
||||
self.value = value
|
||||
self.label = label
|
||||
self.hint = hint
|
||||
}
|
||||
}
|
||||
|
||||
public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
let data = try JSONEncoder().encode(raw)
|
||||
return try JSONDecoder().decode(WizardStep.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] {
|
||||
guard let raw else { return [] }
|
||||
return raw.map { entry in
|
||||
let value = entry["value"]
|
||||
let label = (entry["label"]?.value as? String) ?? ""
|
||||
let hint = entry["hint"]?.value as? String
|
||||
return WizardOption(value: value, label: label, hint: hint)
|
||||
}
|
||||
}
|
||||
|
||||
public func wizardStatusString(_ value: AnyCodable?) -> String? {
|
||||
(value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
public func wizardStepType(_ step: WizardStep) -> String {
|
||||
(step.type.value as? String) ?? ""
|
||||
}
|
||||
|
||||
public func anyCodableString(_ value: AnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as String:
|
||||
string
|
||||
case let int as Int:
|
||||
String(int)
|
||||
case let double as Double:
|
||||
String(double)
|
||||
case let bool as Bool:
|
||||
bool ? "true" : "false"
|
||||
default:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableBool(_ value: AnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as Bool:
|
||||
return bool
|
||||
case let int as Int:
|
||||
return int != 0
|
||||
case let double as Double:
|
||||
return double != 0
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == "true" || trimmed == "1" || trimmed == "yes"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [AnyCodable]:
|
||||
return arr
|
||||
case let arr as [Any]:
|
||||
return arr.map { AnyCodable($0) }
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
l == r
|
||||
case let (l as Int, r as Int):
|
||||
l == r
|
||||
case let (l as Double, r as Double):
|
||||
l == r
|
||||
case let (l as Bool, r as Bool):
|
||||
l == r
|
||||
case let (l as String, r as Int):
|
||||
l == String(r)
|
||||
case let (l as Int, r as String):
|
||||
String(l) == r
|
||||
case let (l as String, r as Double):
|
||||
l == String(r)
|
||||
case let (l as Double, r as String):
|
||||
String(l) == r
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
544
apps/macos/Sources/ClawdbotWizardCLI/main.swift
Normal file
544
apps/macos/Sources/ClawdbotWizardCLI/main.swift
Normal file
@@ -0,0 +1,544 @@
|
||||
import ClawdbotProtocol
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
struct WizardCliOptions {
|
||||
var url: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var mode: String = "local"
|
||||
var workspace: String?
|
||||
var json: Bool = false
|
||||
var help: Bool = false
|
||||
|
||||
static func parse(_ args: [String]) -> WizardCliOptions {
|
||||
var opts = WizardCliOptions()
|
||||
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 "--url":
|
||||
opts.url = self.nextValue(args, index: &i)
|
||||
case "--token":
|
||||
opts.token = self.nextValue(args, index: &i)
|
||||
case "--password":
|
||||
opts.password = self.nextValue(args, index: &i)
|
||||
case "--mode":
|
||||
if let value = nextValue(args, index: &i) {
|
||||
opts.mode = value
|
||||
}
|
||||
case "--workspace":
|
||||
opts.workspace = self.nextValue(args, index: &i)
|
||||
default:
|
||||
break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
private static func nextValue(_ args: [String], index: inout Int) -> String? {
|
||||
guard index + 1 < args.count else { return nil }
|
||||
index += 1
|
||||
return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayConfig {
|
||||
var mode: String?
|
||||
var bind: String?
|
||||
var port: Int?
|
||||
var remoteUrl: String?
|
||||
var token: String?
|
||||
var password: String?
|
||||
var remoteToken: String?
|
||||
var remotePassword: String?
|
||||
}
|
||||
|
||||
enum WizardCliError: Error, CustomStringConvertible {
|
||||
case invalidUrl(String)
|
||||
case missingRemoteUrl
|
||||
case gatewayError(String)
|
||||
case decodeError(String)
|
||||
case cancelled
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case let .invalidUrl(raw): "Invalid URL: \(raw)"
|
||||
case .missingRemoteUrl: "gateway.remote.url is missing"
|
||||
case let .gatewayError(msg): msg
|
||||
case let .decodeError(msg): msg
|
||||
case .cancelled: "Wizard cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct ClawdbotWizardCLI {
|
||||
static func main() async {
|
||||
let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst()))
|
||||
if opts.help {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
let config = loadGatewayConfig()
|
||||
do {
|
||||
guard isatty(STDIN_FILENO) != 0 else {
|
||||
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
|
||||
}
|
||||
let endpoint = try resolveGatewayEndpoint(opts: opts, config: config)
|
||||
let client = GatewayWizardClient(
|
||||
url: endpoint.url,
|
||||
token: endpoint.token,
|
||||
password: endpoint.password,
|
||||
json: opts.json)
|
||||
try await client.connect()
|
||||
defer { Task { await client.close() } }
|
||||
try await runWizard(client: client, opts: opts)
|
||||
} catch {
|
||||
fputs("wizard: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct GatewayEndpoint {
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private func printUsage() {
|
||||
print("""
|
||||
clawdbot-mac-wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
--token <token> Gateway token (if required)
|
||||
--password <pw> Gateway password (if required)
|
||||
--mode <mode> Wizard mode (local|remote). Default: local
|
||||
--workspace <path> Wizard workspace override
|
||||
--json Print raw wizard responses
|
||||
-h, --help Show help
|
||||
""")
|
||||
}
|
||||
|
||||
private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
|
||||
if let raw = opts.url, !raw.isEmpty {
|
||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
let mode = (config.mode ?? "local").lowercased()
|
||||
if mode == "remote" {
|
||||
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
throw WizardCliError.missingRemoteUrl
|
||||
}
|
||||
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
let port = config.port ?? 18789
|
||||
let host = "127.0.0.1"
|
||||
guard let url = URL(string: "ws://\(host):\(port)") else {
|
||||
throw WizardCliError.invalidUrl("ws://\(host):\(port)")
|
||||
}
|
||||
return GatewayEndpoint(
|
||||
url: url,
|
||||
token: resolvedToken(opts: opts, config: config),
|
||||
password: resolvedPassword(opts: opts, config: config))
|
||||
}
|
||||
|
||||
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||
if let token = opts.token, !token.isEmpty { return token }
|
||||
if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
if (config.mode ?? "local").lowercased() == "remote" {
|
||||
return config.remoteToken
|
||||
}
|
||||
return config.token
|
||||
}
|
||||
|
||||
private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> String? {
|
||||
if let password = opts.password, !password.isEmpty { return password }
|
||||
if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty {
|
||||
return password
|
||||
}
|
||||
if (config.mode ?? "local").lowercased() == "remote" {
|
||||
return config.remotePassword
|
||||
}
|
||||
return config.password
|
||||
}
|
||||
|
||||
private func loadGatewayConfig() -> GatewayConfig {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".clawdbot")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return GatewayConfig()
|
||||
}
|
||||
|
||||
var cfg = GatewayConfig()
|
||||
if let gateway = json["gateway"] as? [String: Any] {
|
||||
cfg.mode = gateway["mode"] as? String
|
||||
cfg.bind = gateway["bind"] as? String
|
||||
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
|
||||
|
||||
if let auth = gateway["auth"] as? [String: Any] {
|
||||
cfg.token = auth["token"] as? String
|
||||
cfg.password = auth["password"] as? String
|
||||
}
|
||||
if let remote = gateway["remote"] as? [String: Any] {
|
||||
cfg.remoteUrl = remote["url"] as? String
|
||||
cfg.remoteToken = remote["token"] as? String
|
||||
cfg.remotePassword = remote["password"] as? String
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
private func parseInt(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let number as Int:
|
||||
number
|
||||
case let number as Double:
|
||||
Int(number)
|
||||
case let raw as String:
|
||||
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
actor GatewayWizardClient {
|
||||
private let url: URL
|
||||
private let token: String?
|
||||
private let password: String?
|
||||
private let json: Bool
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let session = URLSession(configuration: .default)
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
init(url: URL, token: String?, password: String?, json: Bool) {
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.json = json
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
let socket = self.session.webSocketTask(with: self.url)
|
||||
socket.maximumMessageSize = 16 * 1024 * 1024
|
||||
socket.resume()
|
||||
self.task = socket
|
||||
try await self.sendConnect()
|
||||
}
|
||||
|
||||
func close() {
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame {
|
||||
guard let task = self.task else {
|
||||
throw WizardCliError.gatewayError("gateway not connected")
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: id,
|
||||
method: method,
|
||||
params: params.map { AnyCodable($0) })
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
let frame = try decodeFrame(message)
|
||||
if case let .res(res) = frame, res.id == id {
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway error"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodePayload<T: Decodable>(_ response: ResponseFrame, as _: T.Type) throws -> T {
|
||||
guard let payload = response.payload else {
|
||||
throw WizardCliError.decodeError("missing payload")
|
||||
}
|
||||
let data = try self.encoder.encode(payload)
|
||||
return try self.decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeFrame(_ message: URLSessionWebSocketTask.Message) throws -> GatewayFrame {
|
||||
let data: Data? = switch message {
|
||||
case let .data(data): data
|
||||
case let .string(text): text.data(using: .utf8)
|
||||
@unknown default: nil
|
||||
}
|
||||
guard let data else {
|
||||
throw WizardCliError.decodeError("empty gateway response")
|
||||
}
|
||||
return try self.decoder.decode(GatewayFrame.self, from: data)
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
guard let task = self.task else {
|
||||
throw WizardCliError.gatewayError("gateway not connected")
|
||||
}
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let client: [String: AnyCodable] = [
|
||||
"id": AnyCodable("clawdbot-macos"),
|
||||
"displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"),
|
||||
"version": AnyCodable("dev"),
|
||||
"platform": AnyCodable(platform),
|
||||
"deviceFamily": AnyCodable("Mac"),
|
||||
"mode": AnyCodable("ui"),
|
||||
"instanceId": AnyCodable(UUID().uuidString),
|
||||
]
|
||||
|
||||
var params: [String: AnyCodable] = [
|
||||
"minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": AnyCodable(client),
|
||||
"caps": AnyCodable([String]()),
|
||||
"locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
|
||||
"userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
]
|
||||
if let token = self.token {
|
||||
params["auth"] = AnyCodable(["token": AnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
params["auth"] = AnyCodable(["password": AnyCodable(password)])
|
||||
}
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
let frame = RequestFrame(
|
||||
type: "req",
|
||||
id: reqId,
|
||||
method: "connect",
|
||||
params: AnyCodable(params))
|
||||
let data = try self.encoder.encode(frame)
|
||||
try await task.send(.data(data))
|
||||
|
||||
let message = try await task.receive()
|
||||
let frameResponse = try decodeFrame(message)
|
||||
guard case let .res(res) = frameResponse, res.id == reqId else {
|
||||
throw WizardCliError.gatewayError("connect failed (unexpected response)")
|
||||
}
|
||||
if res.ok == false {
|
||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||
throw WizardCliError.gatewayError(msg)
|
||||
}
|
||||
_ = try self.decodePayload(res, as: HelloOk.self)
|
||||
}
|
||||
}
|
||||
|
||||
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
|
||||
var params: [String: AnyCodable] = [:]
|
||||
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if mode == "local" || mode == "remote" {
|
||||
params["mode"] = AnyCodable(mode)
|
||||
}
|
||||
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
|
||||
params["workspace"] = AnyCodable(workspace)
|
||||
}
|
||||
|
||||
let startResponse = try await client.request(method: "wizard.start", params: params)
|
||||
let startResult = try await client.decodePayload(startResponse, as: WizardStartResult.self)
|
||||
if opts.json {
|
||||
dumpResult(startResponse)
|
||||
}
|
||||
|
||||
let sessionId = startResult.sessionid
|
||||
var nextResult = WizardNextResult(
|
||||
done: startResult.done,
|
||||
step: startResult.step,
|
||||
status: startResult.status,
|
||||
error: startResult.error)
|
||||
|
||||
do {
|
||||
while true {
|
||||
let status = wizardStatusString(nextResult.status) ?? (nextResult.done ? "done" : "running")
|
||||
if status == "cancelled" {
|
||||
print("Wizard cancelled.")
|
||||
return
|
||||
}
|
||||
if status == "error" || (nextResult.done && nextResult.error != nil) {
|
||||
throw WizardCliError.gatewayError(nextResult.error ?? "wizard error")
|
||||
}
|
||||
if status == "done" || nextResult.done {
|
||||
print("Wizard complete.")
|
||||
return
|
||||
}
|
||||
|
||||
if let step = decodeWizardStep(nextResult.step) {
|
||||
let answer = try promptAnswer(for: step)
|
||||
var answerPayload: [String: AnyCodable] = [
|
||||
"stepId": AnyCodable(step.id),
|
||||
]
|
||||
if !(answer is NSNull) {
|
||||
answerPayload["value"] = AnyCodable(answer)
|
||||
}
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: [
|
||||
"sessionId": AnyCodable(sessionId),
|
||||
"answer": AnyCodable(answerPayload),
|
||||
])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
dumpResult(response)
|
||||
}
|
||||
} else {
|
||||
let response = try await client.request(
|
||||
method: "wizard.next",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
|
||||
if opts.json {
|
||||
dumpResult(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch WizardCliError.cancelled {
|
||||
_ = try? await client.request(
|
||||
method: "wizard.cancel",
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
throw WizardCliError.cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private func dumpResult(_ response: ResponseFrame) {
|
||||
guard let payload = response.payload else {
|
||||
print("{\"error\":\"missing payload\"}")
|
||||
return
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(payload), let text = String(data: data, encoding: .utf8) {
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
|
||||
private func promptAnswer(for step: WizardStep) throws -> Any {
|
||||
let type = wizardStepType(step)
|
||||
if let title = step.title, !title.isEmpty {
|
||||
print("\n\(title)")
|
||||
}
|
||||
if let message = step.message, !message.isEmpty {
|
||||
print(message)
|
||||
}
|
||||
|
||||
switch type {
|
||||
case "note":
|
||||
_ = try readLineWithPrompt("Continue? (enter)")
|
||||
return NSNull()
|
||||
case "progress":
|
||||
_ = try readLineWithPrompt("Continue? (enter)")
|
||||
return NSNull()
|
||||
case "action":
|
||||
_ = try readLineWithPrompt("Run? (enter)")
|
||||
return true
|
||||
case "text":
|
||||
let initial = anyCodableString(step.initialvalue)
|
||||
let prompt = step.placeholder ?? "Value"
|
||||
let value = try readLineWithPrompt("\(prompt)\(initial.isEmpty ? "" : " [\(initial)]")")
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? initial : trimmed
|
||||
case "confirm":
|
||||
let initial = anyCodableBool(step.initialvalue)
|
||||
let value = try readLineWithPrompt("Confirm? (y/n) [\(initial ? "y" : "n")]")
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.isEmpty { return initial }
|
||||
return trimmed == "y" || trimmed == "yes" || trimmed == "true"
|
||||
case "select":
|
||||
return try promptSelect(step)
|
||||
case "multiselect":
|
||||
return try promptMultiSelect(step)
|
||||
default:
|
||||
_ = try readLineWithPrompt("Continue? (enter)")
|
||||
return NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func promptSelect(_ step: WizardStep) throws -> Any {
|
||||
let options = parseWizardOptions(step.options)
|
||||
guard !options.isEmpty else { return NSNull() }
|
||||
for (idx, option) in options.enumerated() {
|
||||
let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : ""
|
||||
print(" [\(idx + 1)] \(option.label)\(hint)")
|
||||
}
|
||||
let initialIndex = options.firstIndex(where: { anyCodableEqual($0.value, step.initialvalue) })
|
||||
let defaultLabel = initialIndex.map { " [\($0 + 1)]" } ?? ""
|
||||
while true {
|
||||
let input = try readLineWithPrompt("Select one\(defaultLabel)")
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty, let initialIndex {
|
||||
return options[initialIndex].value?.value ?? options[initialIndex].label
|
||||
}
|
||||
if trimmed.lowercased() == "q" { throw WizardCliError.cancelled }
|
||||
if let number = Int(trimmed), (1...options.count).contains(number) {
|
||||
let option = options[number - 1]
|
||||
return option.value?.value ?? option.label
|
||||
}
|
||||
print("Invalid selection.")
|
||||
}
|
||||
}
|
||||
|
||||
private func promptMultiSelect(_ step: WizardStep) throws -> [Any] {
|
||||
let options = parseWizardOptions(step.options)
|
||||
guard !options.isEmpty else { return [] }
|
||||
for (idx, option) in options.enumerated() {
|
||||
let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : ""
|
||||
print(" [\(idx + 1)] \(option.label)\(hint)")
|
||||
}
|
||||
let initialValues = anyCodableArray(step.initialvalue)
|
||||
let initialIndices = options.enumerated().compactMap { index, option in
|
||||
initialValues.contains { anyCodableEqual($0, option.value) } ? index + 1 : nil
|
||||
}
|
||||
let defaultLabel = initialIndices.isEmpty ? "" : " [\(initialIndices.map(String.init).joined(separator: ","))]"
|
||||
while true {
|
||||
let input = try readLineWithPrompt("Select (comma-separated)\(defaultLabel)")
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return initialIndices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label }
|
||||
}
|
||||
if trimmed.lowercased() == "q" { throw WizardCliError.cancelled }
|
||||
let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
let indices = parts.compactMap { Int($0) }.filter { (1...options.count).contains($0) }
|
||||
if indices.isEmpty {
|
||||
print("Invalid selection.")
|
||||
continue
|
||||
}
|
||||
return indices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label }
|
||||
}
|
||||
}
|
||||
|
||||
private func readLineWithPrompt(_ prompt: String) throws -> String {
|
||||
print("\(prompt): ", terminator: "")
|
||||
guard let line = readLine() else {
|
||||
throw WizardCliError.cancelled
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -5,39 +5,30 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct CLIInstallerTests {
|
||||
@Test func installedLocationOnlyAcceptsEmbeddedHelper() throws {
|
||||
@Test func installedLocationFindsExecutable() throws {
|
||||
let fm = FileManager.default
|
||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||
"clawdbot-cli-installer-\(UUID().uuidString)")
|
||||
defer { try? fm.removeItem(at: root) }
|
||||
|
||||
let embedded = root.appendingPathComponent("Relay/clawdbot")
|
||||
try fm.createDirectory(at: embedded.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
fm.createFile(atPath: embedded.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: embedded.path)
|
||||
|
||||
let binDir = root.appendingPathComponent("bin")
|
||||
try fm.createDirectory(at: binDir, withIntermediateDirectories: true)
|
||||
let link = binDir.appendingPathComponent("clawdbot")
|
||||
try fm.createSymbolicLink(at: link, withDestinationURL: embedded)
|
||||
let cli = binDir.appendingPathComponent("clawdbot")
|
||||
fm.createFile(atPath: cli.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cli.path)
|
||||
|
||||
let found = CLIInstaller.installedLocation(
|
||||
searchPaths: [binDir.path],
|
||||
embeddedHelper: embedded,
|
||||
fileManager: fm)
|
||||
#expect(found == link.path)
|
||||
#expect(found == cli.path)
|
||||
|
||||
try fm.removeItem(at: link)
|
||||
let other = root.appendingPathComponent("Other/clawdbot")
|
||||
try fm.createDirectory(at: other.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
fm.createFile(atPath: other.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: other.path)
|
||||
try fm.createSymbolicLink(at: link, withDestinationURL: other)
|
||||
try fm.removeItem(at: cli)
|
||||
fm.createFile(atPath: cli.path, contents: Data())
|
||||
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: cli.path)
|
||||
|
||||
let rejected = CLIInstaller.installedLocation(
|
||||
let missing = CLIInstaller.installedLocation(
|
||||
searchPaths: [binDir.path],
|
||||
embeddedHelper: embedded,
|
||||
fileManager: fm)
|
||||
#expect(rejected == nil)
|
||||
#expect(missing == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,68 +9,76 @@ struct ConnectionsSettingsSmokeTests {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ProvidersStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
whatsapp: ProvidersStatusSnapshot.WhatsAppStatus(
|
||||
configured: true,
|
||||
linked: true,
|
||||
authAgeMs: 86_400_000,
|
||||
self: ProvidersStatusSnapshot.WhatsAppSelf(
|
||||
e164: "+15551234567",
|
||||
jid: nil),
|
||||
running: true,
|
||||
connected: false,
|
||||
lastConnectedAt: 1_700_000_000_000,
|
||||
lastDisconnect: ProvidersStatusSnapshot.WhatsAppDisconnect(
|
||||
at: 1_700_000_050_000,
|
||||
status: 401,
|
||||
error: "logged out",
|
||||
loggedOut: true),
|
||||
reconnectAttempts: 2,
|
||||
lastMessageAt: 1_700_000_060_000,
|
||||
lastEventAt: 1_700_000_060_000,
|
||||
lastError: "needs login"),
|
||||
telegram: ProvidersStatusSnapshot.TelegramStatus(
|
||||
configured: true,
|
||||
tokenSource: "env",
|
||||
running: true,
|
||||
mode: "polling",
|
||||
lastStartAt: 1_700_000_000_000,
|
||||
lastStopAt: nil,
|
||||
lastError: nil,
|
||||
probe: ProvidersStatusSnapshot.TelegramProbe(
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: nil,
|
||||
elapsedMs: 120,
|
||||
bot: ProvidersStatusSnapshot.TelegramBot(id: 123, username: "clawdbotbot"),
|
||||
webhook: ProvidersStatusSnapshot.TelegramWebhook(
|
||||
url: "https://example.com/hook",
|
||||
hasCustomCert: false)),
|
||||
lastProbeAt: 1_700_000_050_000),
|
||||
discord: nil,
|
||||
signal: ProvidersStatusSnapshot.SignalStatus(
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
running: true,
|
||||
lastStartAt: 1_700_000_000_000,
|
||||
lastStopAt: nil,
|
||||
lastError: nil,
|
||||
probe: ProvidersStatusSnapshot.SignalProbe(
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: nil,
|
||||
elapsedMs: 140,
|
||||
version: "0.12.4"),
|
||||
lastProbeAt: 1_700_000_050_000),
|
||||
imessage: ProvidersStatusSnapshot.IMessageStatus(
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
cliPath: nil,
|
||||
dbPath: nil,
|
||||
probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"),
|
||||
lastProbeAt: 1_700_000_050_000))
|
||||
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
providerLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
providers: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": true,
|
||||
"linked": true,
|
||||
"authAgeMs": 86_400_000,
|
||||
"self": ["e164": "+15551234567"],
|
||||
"running": true,
|
||||
"connected": false,
|
||||
"lastConnectedAt": 1_700_000_000_000,
|
||||
"lastDisconnect": [
|
||||
"at": 1_700_000_050_000,
|
||||
"status": 401,
|
||||
"error": "logged out",
|
||||
"loggedOut": true,
|
||||
],
|
||||
"reconnectAttempts": 2,
|
||||
"lastMessageAt": 1_700_000_060_000,
|
||||
"lastEventAt": 1_700_000_060_000,
|
||||
"lastError": "needs login",
|
||||
]),
|
||||
"telegram": AnyCodable([
|
||||
"configured": true,
|
||||
"tokenSource": "env",
|
||||
"running": true,
|
||||
"mode": "polling",
|
||||
"lastStartAt": 1_700_000_000_000,
|
||||
"probe": [
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"elapsedMs": 120,
|
||||
"bot": ["id": 123, "username": "clawdbotbot"],
|
||||
"webhook": ["url": "https://example.com/hook", "hasCustomCert": false],
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"signal": AnyCodable([
|
||||
"configured": true,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": true,
|
||||
"lastStartAt": 1_700_000_000_000,
|
||||
"probe": [
|
||||
"ok": true,
|
||||
"status": 200,
|
||||
"elapsedMs": 140,
|
||||
"version": "0.12.4",
|
||||
],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
"imessage": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_050_000,
|
||||
]),
|
||||
],
|
||||
providerAccounts: [:],
|
||||
providerDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
store.whatsappLoginMessage = "Scan QR"
|
||||
store.whatsappLoginQrDataUrl =
|
||||
@@ -91,60 +99,62 @@ struct ConnectionsSettingsSmokeTests {
|
||||
let store = ConnectionsStore(isPreview: true)
|
||||
store.snapshot = ProvidersStatusSnapshot(
|
||||
ts: 1_700_000_000_000,
|
||||
whatsapp: ProvidersStatusSnapshot.WhatsAppStatus(
|
||||
configured: false,
|
||||
linked: false,
|
||||
authAgeMs: nil,
|
||||
self: nil,
|
||||
running: false,
|
||||
connected: false,
|
||||
lastConnectedAt: nil,
|
||||
lastDisconnect: nil,
|
||||
reconnectAttempts: 0,
|
||||
lastMessageAt: nil,
|
||||
lastEventAt: nil,
|
||||
lastError: nil),
|
||||
telegram: ProvidersStatusSnapshot.TelegramStatus(
|
||||
configured: false,
|
||||
tokenSource: nil,
|
||||
running: false,
|
||||
mode: nil,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "bot missing",
|
||||
probe: ProvidersStatusSnapshot.TelegramProbe(
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "unauthorized",
|
||||
elapsedMs: 120,
|
||||
bot: nil,
|
||||
webhook: nil),
|
||||
lastProbeAt: 1_700_000_100_000),
|
||||
discord: nil,
|
||||
signal: ProvidersStatusSnapshot.SignalStatus(
|
||||
configured: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
probe: ProvidersStatusSnapshot.SignalProbe(
|
||||
ok: false,
|
||||
status: 404,
|
||||
error: "unreachable",
|
||||
elapsedMs: 200,
|
||||
version: nil),
|
||||
lastProbeAt: 1_700_000_200_000),
|
||||
imessage: ProvidersStatusSnapshot.IMessageStatus(
|
||||
configured: false,
|
||||
running: false,
|
||||
lastStartAt: nil,
|
||||
lastStopAt: nil,
|
||||
lastError: "not configured",
|
||||
cliPath: "imsg",
|
||||
dbPath: nil,
|
||||
probe: ProvidersStatusSnapshot.IMessageProbe(ok: false, error: "imsg not found (imsg)"),
|
||||
lastProbeAt: 1_700_000_200_000))
|
||||
providerOrder: ["whatsapp", "telegram", "signal", "imessage"],
|
||||
providerLabels: [
|
||||
"whatsapp": "WhatsApp",
|
||||
"telegram": "Telegram",
|
||||
"signal": "Signal",
|
||||
"imessage": "iMessage",
|
||||
],
|
||||
providers: [
|
||||
"whatsapp": AnyCodable([
|
||||
"configured": false,
|
||||
"linked": false,
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"reconnectAttempts": 0,
|
||||
]),
|
||||
"telegram": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "bot missing",
|
||||
"probe": [
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
"error": "unauthorized",
|
||||
"elapsedMs": 120,
|
||||
],
|
||||
"lastProbeAt": 1_700_000_100_000,
|
||||
]),
|
||||
"signal": AnyCodable([
|
||||
"configured": false,
|
||||
"baseUrl": "http://127.0.0.1:8080",
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"probe": [
|
||||
"ok": false,
|
||||
"status": 404,
|
||||
"error": "unreachable",
|
||||
"elapsedMs": 200,
|
||||
],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
"imessage": AnyCodable([
|
||||
"configured": false,
|
||||
"running": false,
|
||||
"lastError": "not configured",
|
||||
"cliPath": "imsg",
|
||||
"probe": ["ok": false, "error": "imsg not found (imsg)"],
|
||||
"lastProbeAt": 1_700_000_200_000,
|
||||
]),
|
||||
],
|
||||
providerAccounts: [:],
|
||||
providerDefaultAccountId: [
|
||||
"whatsapp": "default",
|
||||
"telegram": "default",
|
||||
"signal": "default",
|
||||
"imessage": "default",
|
||||
])
|
||||
|
||||
let view = ConnectionsSettings(store: store)
|
||||
_ = view.body
|
||||
|
||||
@@ -23,8 +23,11 @@ struct CronJobEditorSmokeTests {
|
||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_000_000,
|
||||
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
|
||||
@@ -65,4 +68,24 @@ struct CronJobEditorSmokeTests {
|
||||
onSave: { _ in })
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
var view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.name = "One-shot"
|
||||
view.sessionTarget = .main
|
||||
view.payloadKind = .systemEvent
|
||||
view.systemEventText = "hello"
|
||||
view.scheduleKind = .at
|
||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
view.deleteAfterRun = true
|
||||
|
||||
let payload = try view.buildPayload()
|
||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,27 @@ struct CronModelsTests {
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: nil,
|
||||
name: "One-shot",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 1_700_000_000_000),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
#expect(decoded.deleteAfterRun == true)
|
||||
}
|
||||
|
||||
@Test func scheduleDecodeRejectsUnknownKind() {
|
||||
let json = """
|
||||
{"kind":"wat","atMs":1}
|
||||
@@ -60,9 +81,11 @@ struct CronModelsTests {
|
||||
@Test func displayNameTrimsWhitespaceAndFallsBack() {
|
||||
let base = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: " hello ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
@@ -81,9 +104,11 @@ struct CronModelsTests {
|
||||
@Test func nextRunDateAndLastRunDateDeriveFromState() {
|
||||
let job = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: "t",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
|
||||
@@ -13,19 +13,12 @@ struct GatewayAutostartPolicyTests {
|
||||
@Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() {
|
||||
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
attachExistingOnly: false))
|
||||
paused: false))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
attachExistingOnly: true))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: true,
|
||||
attachExistingOnly: false))
|
||||
paused: true))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .remote,
|
||||
paused: false,
|
||||
attachExistingOnly: false))
|
||||
paused: false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,53 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
@testable import ClawdbotIPC
|
||||
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
var state: URLSessionTask.State = .running
|
||||
|
||||
func resume() {}
|
||||
|
||||
func cancel(with _: URLSessionWebSocketTask.CloseCode, reason _: Data?) {
|
||||
self.state = .canceling
|
||||
}
|
||||
|
||||
func send(_: URLSessionWebSocketTask.Message) async throws {}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
completionHandler(.failure(URLError(.cannotConnectToHost)))
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
func makeWebSocketTask(url _: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: FakeWebSocketTask())
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTestGatewayConnection() -> GatewayConnection {
|
||||
GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: FakeWebSocketSession()))
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControlTests {
|
||||
@Test func statusFailsWhenProcessMissing() async {
|
||||
let result = await GatewayConnection.shared.status()
|
||||
// We don't assert ok because the worker may not be available in CI.
|
||||
// Instead, ensure the call returns without throwing and provides a message.
|
||||
#expect(result.ok == true || result.error != nil)
|
||||
let connection = makeTestGatewayConnection()
|
||||
let result = await connection.status()
|
||||
#expect(result.ok == false)
|
||||
#expect(result.error != nil)
|
||||
}
|
||||
|
||||
@Test func rejectEmptyMessage() async {
|
||||
let result = await GatewayConnection.shared.sendAgent(
|
||||
let connection = makeTestGatewayConnection()
|
||||
let result = await connection.sendAgent(
|
||||
message: "",
|
||||
thinking: nil,
|
||||
sessionKey: "main",
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -5,14 +5,14 @@ import Testing
|
||||
@Suite struct HealthDecodeTests {
|
||||
private let sampleJSON: String = // minimal but complete payload
|
||||
"""
|
||||
{"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
{"ts":1733622000,"durationMs":420,"providers":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"providerOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
|
||||
"""
|
||||
|
||||
@Test func decodesCleanJSON() async throws {
|
||||
let data = Data(sampleJSON.utf8)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
|
||||
#expect(snap?.web.linked == true)
|
||||
#expect(snap?.providers["whatsapp"]?.linked == true)
|
||||
#expect(snap?.sessions.count == 1)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import Testing
|
||||
let noisy = "debug: something logged\n" + self.sampleJSON + "\ntrailer"
|
||||
let snap = decodeHealthSnapshot(from: Data(noisy.utf8))
|
||||
|
||||
#expect(snap?.web.connect?.status == 200)
|
||||
#expect(snap?.providers["telegram"]?.probe?.elapsedMs == 800)
|
||||
}
|
||||
|
||||
@Test func failsWithoutBraces() async throws {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct HealthStoreStateTests {
|
||||
@Test @MainActor func linkedProviderProbeFailureDegradesState() async throws {
|
||||
let snap = HealthSnapshot(
|
||||
ok: true,
|
||||
ts: 0,
|
||||
durationMs: 1,
|
||||
providers: [
|
||||
"whatsapp": .init(
|
||||
configured: true,
|
||||
linked: true,
|
||||
authAgeMs: 1,
|
||||
probe: .init(
|
||||
ok: false,
|
||||
status: 503,
|
||||
error: "gateway connect failed",
|
||||
elapsedMs: 12,
|
||||
bot: nil,
|
||||
webhook: nil),
|
||||
lastProbeAt: 0),
|
||||
],
|
||||
providerOrder: ["whatsapp"],
|
||||
providerLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
|
||||
|
||||
let store = HealthStore.shared
|
||||
store.__setSnapshotForTest(snap, lastError: nil)
|
||||
|
||||
switch store.state {
|
||||
case let .degraded(message):
|
||||
#expect(!message.isEmpty)
|
||||
default:
|
||||
Issue.record("Expected degraded state when probe fails for linked provider")
|
||||
}
|
||||
|
||||
#expect(store.summaryLine.contains("probe degraded"))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user