mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
1649 Commits
codex/8606
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478b599e9b | ||
|
|
0b0edcdf1c | ||
|
|
57c88dd46e | ||
|
|
654de643e4 | ||
|
|
ee2b90b4e2 | ||
|
|
f59fc0d477 | ||
|
|
1cab722fe0 | ||
|
|
4739f0cfe2 | ||
|
|
2442e9c178 | ||
|
|
0ac61072b8 | ||
|
|
31099ccb1f | ||
|
|
2d23519c77 | ||
|
|
bbd2854c45 | ||
|
|
5d3a6909fb | ||
|
|
1e0c9d8174 | ||
|
|
4c16bd2453 | ||
|
|
cd3d960ec5 | ||
|
|
d93394e29b | ||
|
|
83dff5855e | ||
|
|
3402477314 | ||
|
|
71b3bc87ca | ||
|
|
0be3ef5a38 | ||
|
|
287687da20 | ||
|
|
22e4289d3f | ||
|
|
5367ef7bd3 | ||
|
|
598e177e12 | ||
|
|
0ed9fb48c4 | ||
|
|
3ea911558c | ||
|
|
443255461c | ||
|
|
7dde396d4d | ||
|
|
89975eea24 | ||
|
|
dbd3e10312 | ||
|
|
8b50cdd151 | ||
|
|
a825b5576b | ||
|
|
76b300babc | ||
|
|
ada22739be | ||
|
|
8fe50a2136 | ||
|
|
b374505e7a | ||
|
|
653292901a | ||
|
|
3f50485156 | ||
|
|
9c2744f1e1 | ||
|
|
3aa460409e | ||
|
|
48980a0f41 | ||
|
|
eb170a0adb | ||
|
|
b7232db1b0 | ||
|
|
a20b2dc740 | ||
|
|
c6b1fede5a | ||
|
|
c80ec43325 | ||
|
|
b6891d284d | ||
|
|
ec78a21e0b | ||
|
|
be3af54f98 | ||
|
|
3fc0df953c | ||
|
|
4ac90a5b48 | ||
|
|
39e987314a | ||
|
|
427df01d4e | ||
|
|
50b7a2ffa1 | ||
|
|
b93ed3f93f | ||
|
|
a2b2c4a76c | ||
|
|
a9a86f788b | ||
|
|
371a8abe9d | ||
|
|
005da57957 | ||
|
|
d11e82aeea | ||
|
|
adcac404e1 | ||
|
|
eb5e80f58a | ||
|
|
5891cfec3e | ||
|
|
961691def2 | ||
|
|
2780f540f8 | ||
|
|
37058ad75a | ||
|
|
37c6e2dfa0 | ||
|
|
473993f73a | ||
|
|
e24a9c5457 | ||
|
|
d9c0d09f1a | ||
|
|
0c7ab411e5 | ||
|
|
5811693c7f | ||
|
|
445ff22018 | ||
|
|
602364f1c7 | ||
|
|
c73e8eedf4 | ||
|
|
dcc329ac09 | ||
|
|
515d4ffc21 | ||
|
|
28290a496f | ||
|
|
3cd368edec | ||
|
|
908fc35b97 | ||
|
|
d6d1cc2a3e | ||
|
|
41044a207c | ||
|
|
7d19f89094 | ||
|
|
43e4b9dc1c | ||
|
|
77c6bee421 | ||
|
|
0b49710e8d | ||
|
|
3a9e7dfa1a | ||
|
|
e4905ce4c9 | ||
|
|
131e662924 | ||
|
|
7051bf16f0 | ||
|
|
6eb6730137 | ||
|
|
2383cfd303 | ||
|
|
c09e1efe99 | ||
|
|
308fdbe7fb | ||
|
|
c5af09e378 | ||
|
|
3037646d22 | ||
|
|
3ea82adf97 | ||
|
|
bc6d570659 | ||
|
|
f7729028ae | ||
|
|
11d6ce15e8 | ||
|
|
897a7efe15 | ||
|
|
fafa4c8b65 | ||
|
|
186182fe9e | ||
|
|
e5455b61c3 | ||
|
|
e89417d77b | ||
|
|
e9b0a5f69e | ||
|
|
339e212c85 | ||
|
|
199cdc1052 | ||
|
|
ab0613c9d3 | ||
|
|
91ba5fd4fe | ||
|
|
9da7498d31 | ||
|
|
67298c4bd8 | ||
|
|
9417a3f39f | ||
|
|
1609fcaff3 | ||
|
|
5205b94d84 | ||
|
|
7f48ee1e57 | ||
|
|
c4be8d8730 | ||
|
|
bc1c3701c4 | ||
|
|
4e8b74568f | ||
|
|
b80dcbd650 | ||
|
|
417aba7b9b | ||
|
|
ed63523db9 | ||
|
|
677f7c80dc | ||
|
|
231d0b28bd | ||
|
|
979907e004 | ||
|
|
9eb17a0277 | ||
|
|
06e0fd3347 | ||
|
|
51cceaf70c | ||
|
|
471164afbd | ||
|
|
99ce71ddbb | ||
|
|
9cb9851bf8 | ||
|
|
2b31c02163 | ||
|
|
878e433d81 | ||
|
|
dfbed5053a | ||
|
|
caac9733a7 | ||
|
|
6399b6a445 | ||
|
|
472606de9b | ||
|
|
177496552b | ||
|
|
e0248fc11f | ||
|
|
6a753ade78 | ||
|
|
53812bd8aa | ||
|
|
fe3c3ac5cd | ||
|
|
5435b453ca | ||
|
|
abc26b072b | ||
|
|
64533bab65 | ||
|
|
7d4bf8f285 | ||
|
|
bdb0fde0ea | ||
|
|
926a165a52 | ||
|
|
70b6fdd149 | ||
|
|
9ad7f5bbde | ||
|
|
1ee751ddb1 | ||
|
|
30e3ca08a5 | ||
|
|
1f6c1eacf0 | ||
|
|
8654353be8 | ||
|
|
c5aa3ff02f | ||
|
|
5fde637ba8 | ||
|
|
044f5a814e | ||
|
|
3ae521745e | ||
|
|
f89f5d930f | ||
|
|
13c77f00c3 | ||
|
|
3b8ab4e112 | ||
|
|
ca4a12381a | ||
|
|
86e33d6985 | ||
|
|
9ef699fedc | ||
|
|
912a276ca1 | ||
|
|
6f20f29688 | ||
|
|
b6d253eefb | ||
|
|
0a87f6e4ad | ||
|
|
bc77f7a00a | ||
|
|
9e3d5310cc | ||
|
|
4d9366fecb | ||
|
|
1c9851e115 | ||
|
|
a4f62400a7 | ||
|
|
8d3fe21b53 | ||
|
|
becd45325b | ||
|
|
84a965a1a2 | ||
|
|
f4d461bbff | ||
|
|
cbad1b6e69 | ||
|
|
f4cd5e4050 | ||
|
|
0e7773d1a6 | ||
|
|
d8e7734d27 | ||
|
|
da7fb64aa4 | ||
|
|
3fffb34ba0 | ||
|
|
0dd67e2f25 | ||
|
|
4df27b9626 | ||
|
|
e708a872a1 | ||
|
|
2dacc6da28 | ||
|
|
9660e42fe2 | ||
|
|
522da25932 | ||
|
|
d44621b544 | ||
|
|
6fe0539992 | ||
|
|
283238fd77 | ||
|
|
5568ecc7aa | ||
|
|
743d5378d2 | ||
|
|
63a3676d3c | ||
|
|
2a39c217c8 | ||
|
|
a2fc4ca7ad | ||
|
|
8eeaa45729 | ||
|
|
4d13055ca5 | ||
|
|
bfceffa2f7 | ||
|
|
031583e8f5 | ||
|
|
2ccbc673df | ||
|
|
11b5728faa | ||
|
|
4decdf6245 | ||
|
|
ac0fb976c8 | ||
|
|
1de9f99ea8 | ||
|
|
60f8e18372 | ||
|
|
e52b4bce01 | ||
|
|
f93a558892 | ||
|
|
5ba3505fed | ||
|
|
18e7d28b21 | ||
|
|
02ca283716 | ||
|
|
4f0e3cb621 | ||
|
|
73a69d9e64 | ||
|
|
b1911a7cd3 | ||
|
|
450642a897 | ||
|
|
f7a1903bfc | ||
|
|
61cf22f147 | ||
|
|
55505776fb | ||
|
|
3c91928bae | ||
|
|
6ac7564918 | ||
|
|
23e1aac9b2 | ||
|
|
c65af78853 | ||
|
|
4155ac1c0d | ||
|
|
cfe5544b30 | ||
|
|
d7b901a1e7 | ||
|
|
5225a8c644 | ||
|
|
fc50f949d4 | ||
|
|
f6b40861f7 | ||
|
|
f491d420f7 | ||
|
|
ef0882e17e | ||
|
|
697bafa9c9 | ||
|
|
77761f4a3e | ||
|
|
0e2694ff47 | ||
|
|
5eb71927b7 | ||
|
|
cbd8049b9f | ||
|
|
19f22b5924 | ||
|
|
05634708e0 | ||
|
|
536c00991f | ||
|
|
c94c43d3bb | ||
|
|
8a99c0d17a | ||
|
|
30e1556cda | ||
|
|
ec15f90a55 | ||
|
|
3da34a4673 | ||
|
|
f91ddefbfb | ||
|
|
84385898ec | ||
|
|
6c7642b532 | ||
|
|
9988a37d37 | ||
|
|
37b33d11ce | ||
|
|
7086e34533 | ||
|
|
20fbb8bd14 | ||
|
|
8e90a1cad9 | ||
|
|
7e3ebb8e10 | ||
|
|
06b2bf1c0a | ||
|
|
d649548a7a | ||
|
|
5adc681238 | ||
|
|
53e8dc6a54 | ||
|
|
2d0a0c5e43 | ||
|
|
b668ffe7ca | ||
|
|
6736936cbc | ||
|
|
8539e0283a | ||
|
|
ef88f0f949 | ||
|
|
816c692035 | ||
|
|
c635e560d0 | ||
|
|
ccb59d989b | ||
|
|
642f85dc5b | ||
|
|
53300a5c1a | ||
|
|
b51610a1c3 | ||
|
|
5269924ff8 | ||
|
|
62fa5692cb | ||
|
|
2d4369d176 | ||
|
|
99e8cf22a8 | ||
|
|
e780a6b7ba | ||
|
|
313554059c | ||
|
|
77b334a984 | ||
|
|
ab67a198c1 | ||
|
|
9ef5a9afdc | ||
|
|
c39fbdb698 | ||
|
|
d33d6bfafa | ||
|
|
2209f71a78 | ||
|
|
f13a615036 | ||
|
|
5660b67062 | ||
|
|
1d21646e96 | ||
|
|
55d4456751 | ||
|
|
a80d9f00f1 | ||
|
|
22d635080d | ||
|
|
d5be702f86 | ||
|
|
3d66d203d0 | ||
|
|
a918e93421 | ||
|
|
56eadf36d0 | ||
|
|
912f663173 | ||
|
|
f44af7eebf | ||
|
|
65fe2b7e91 | ||
|
|
941e04e9f3 | ||
|
|
f327073fb3 | ||
|
|
41e5acbb6c | ||
|
|
2333d47a1e | ||
|
|
c9e481ac48 | ||
|
|
462e315953 | ||
|
|
6b14df7792 | ||
|
|
e449392c4f | ||
|
|
326db58229 | ||
|
|
3caf4facec | ||
|
|
c9a97f54e0 | ||
|
|
85506c36a0 | ||
|
|
a176b8ec2f | ||
|
|
2b726457d8 | ||
|
|
6464f8d1d9 | ||
|
|
a17c7a56da | ||
|
|
98a1aa491f | ||
|
|
25b87b111d | ||
|
|
f823123aa5 | ||
|
|
d717ff71bf | ||
|
|
840192caa9 | ||
|
|
61ef6b12dd | ||
|
|
660a6dec7f | ||
|
|
e49ef86945 | ||
|
|
d2f69ecc3b | ||
|
|
a89abcb1e9 | ||
|
|
8bf7bc5b5c | ||
|
|
4e2ef87c31 | ||
|
|
ec58491f75 | ||
|
|
0840fea50d | ||
|
|
cf60e83118 | ||
|
|
7ad2ebb515 | ||
|
|
3c41e1722f | ||
|
|
dd5b70bcc4 | ||
|
|
30c0422a8e | ||
|
|
6d43200248 | ||
|
|
be3153cabb | ||
|
|
56995069f1 | ||
|
|
2238e0ce76 | ||
|
|
38a463fe93 | ||
|
|
e1f462b352 | ||
|
|
ccd635fdb9 | ||
|
|
27dce6c6bb | ||
|
|
9c08d8cd35 | ||
|
|
dc5b3ecc4c | ||
|
|
95f66a34e7 | ||
|
|
1695ee2f43 | ||
|
|
801520b0f0 | ||
|
|
8ba79d72b4 | ||
|
|
5876ba6152 | ||
|
|
5b895f2592 | ||
|
|
fb61363763 | ||
|
|
07e0af44b3 | ||
|
|
059d5405fe | ||
|
|
cd37dbd4e5 | ||
|
|
3e8d06a6be | ||
|
|
2f07e4e6c0 | ||
|
|
15fb3314de | ||
|
|
5a019e7725 | ||
|
|
aea31934d4 | ||
|
|
8ec7e80cb2 | ||
|
|
6c3533d8c4 | ||
|
|
9c313a7826 | ||
|
|
368a719879 | ||
|
|
ec7e3eaf64 | ||
|
|
8bcdab8933 | ||
|
|
c2f0d811e7 | ||
|
|
8f3d3a549d | ||
|
|
d389a52494 | ||
|
|
346b14a51a | ||
|
|
ffa2da8478 | ||
|
|
61a768be75 | ||
|
|
3d8a77a113 | ||
|
|
a6a358f1a6 | ||
|
|
131dc4eaeb | ||
|
|
022fd55bad | ||
|
|
d9820e4098 | ||
|
|
a4ebdc9aa1 | ||
|
|
cf2461f7f6 | ||
|
|
f5f829db79 | ||
|
|
a06daab97e | ||
|
|
09f094057a | ||
|
|
9def042fab | ||
|
|
f6adea5757 | ||
|
|
78f4a5c05f | ||
|
|
731a7af9c5 | ||
|
|
ffa4342a6a | ||
|
|
550a134cf9 | ||
|
|
1b43e84d0d | ||
|
|
31f0635f4f | ||
|
|
1c65e2e7c1 | ||
|
|
b6f3fe7938 | ||
|
|
d65b3a68aa | ||
|
|
e2b54fecd8 | ||
|
|
b8067d073a | ||
|
|
e420c001d0 | ||
|
|
44b6b79a66 | ||
|
|
3ef2935ac9 | ||
|
|
fced29de17 | ||
|
|
4f074c3235 | ||
|
|
5df00520cb | ||
|
|
b2c85bc0a2 | ||
|
|
5e2e78a75a | ||
|
|
2196f107da | ||
|
|
ff56a2d7b3 | ||
|
|
24cff8a3bc | ||
|
|
b495ac2abb | ||
|
|
3f2585424d | ||
|
|
9d1a3007d9 | ||
|
|
b5c163dffa | ||
|
|
ee0cf9e5bb | ||
|
|
37fdfa0e0b | ||
|
|
d550b804b8 | ||
|
|
05988500bc | ||
|
|
b01290cf64 | ||
|
|
117f6fb254 | ||
|
|
c363816fea | ||
|
|
aeed31cdb1 | ||
|
|
58c8c022c5 | ||
|
|
2cfae61743 | ||
|
|
c6b4daf426 | ||
|
|
348fabe04d | ||
|
|
6c83e8e7e4 | ||
|
|
817b6259c4 | ||
|
|
959af0fa5b | ||
|
|
669b26a3dc | ||
|
|
67c139fc36 | ||
|
|
8b6829e1bc | ||
|
|
86e6fbcf52 | ||
|
|
9b4b3aa348 | ||
|
|
51ab2c0d79 | ||
|
|
bdd9c70787 | ||
|
|
1ff95ff3e6 | ||
|
|
7c5b55c5ff | ||
|
|
b0d6076208 | ||
|
|
4385e57dce | ||
|
|
eb45c1c623 | ||
|
|
adf981de89 | ||
|
|
023a101b91 | ||
|
|
8b92aca27f | ||
|
|
b13fb788b5 | ||
|
|
87c0ee7685 | ||
|
|
eef32e94c7 | ||
|
|
1350efcfd8 | ||
|
|
e7ef051149 | ||
|
|
2b5ddf8f2a | ||
|
|
6f655573d3 | ||
|
|
8aabf45ddb | ||
|
|
4d4748e807 | ||
|
|
439c09668e | ||
|
|
54bbe87cd5 | ||
|
|
6804b7cb71 | ||
|
|
63470e99f0 | ||
|
|
90b0f7bd85 | ||
|
|
d92b3b5cc2 | ||
|
|
4d0668a546 | ||
|
|
2c0f79d53a | ||
|
|
5374c7a8a2 | ||
|
|
35ce103378 | ||
|
|
029c17de41 | ||
|
|
6b41a0692f | ||
|
|
da5d1a6215 | ||
|
|
f72762ae8f | ||
|
|
0185d0d2ac | ||
|
|
82fe55acac | ||
|
|
28eb4cfa12 | ||
|
|
a9cbec912e | ||
|
|
095bc6d4b4 | ||
|
|
b72853a742 | ||
|
|
4f784b5d47 | ||
|
|
ff2a99b22e | ||
|
|
de1dfab03e | ||
|
|
7b699fddac | ||
|
|
7854f547ce | ||
|
|
e64d713e41 | ||
|
|
8348af99e8 | ||
|
|
b1958256fd | ||
|
|
65fc5d1c5d | ||
|
|
b19584b25e | ||
|
|
069ea7942d | ||
|
|
5d75f64369 | ||
|
|
7666d71fab | ||
|
|
25affd6584 | ||
|
|
d8db7f561e | ||
|
|
26ef325219 | ||
|
|
86311b0e00 | ||
|
|
b09cab4ebd | ||
|
|
7d71c5d0c6 | ||
|
|
b13529767b | ||
|
|
cc42367f3f | ||
|
|
915f88a0a3 | ||
|
|
cec50aa047 | ||
|
|
fc90f0f15c | ||
|
|
0d4828497e | ||
|
|
aae0d54752 | ||
|
|
650027106b | ||
|
|
99ffd714ce | ||
|
|
0f8ea1d3d9 | ||
|
|
8d8f5a59e2 | ||
|
|
fcf2852f0f | ||
|
|
7054aa562e | ||
|
|
dad8cfaf74 | ||
|
|
5f4fc7512e | ||
|
|
530351e394 | ||
|
|
f52355ce5f | ||
|
|
9b605846bb | ||
|
|
26bf8f0dc8 | ||
|
|
3fbd2432b6 | ||
|
|
f90b8cffc7 | ||
|
|
1ac037d948 | ||
|
|
45c4f1edd4 | ||
|
|
be76841143 | ||
|
|
89e64f70c1 | ||
|
|
e35db953eb | ||
|
|
032945a5cd | ||
|
|
f61a5bc797 | ||
|
|
0915b72bcf | ||
|
|
7840fdbada | ||
|
|
4abde61366 | ||
|
|
4291e32777 | ||
|
|
453f40d5bf | ||
|
|
470fc879e8 | ||
|
|
311c1a05eb | ||
|
|
7c3d7fc6e3 | ||
|
|
94df665cdc | ||
|
|
7c1484d637 | ||
|
|
be2c43ee3e | ||
|
|
5aa2bd7921 | ||
|
|
0a7ecd5428 | ||
|
|
5db2cd6c00 | ||
|
|
81505ada18 | ||
|
|
8edeba0de3 | ||
|
|
beb42b12c9 | ||
|
|
42b320ad65 | ||
|
|
bba8015688 | ||
|
|
05e31bbedd | ||
|
|
c806a736af | ||
|
|
ceb179f84d | ||
|
|
72a2cc0acb | ||
|
|
cd07d013ba | ||
|
|
afa6d0cd18 | ||
|
|
aa0d6e1bca | ||
|
|
17e75f8641 | ||
|
|
d69ee6777d | ||
|
|
344aff383b | ||
|
|
56f46a2581 | ||
|
|
62abfd3dcb | ||
|
|
c536bd6af1 | ||
|
|
fcdc25ba64 | ||
|
|
9090f6b1c4 | ||
|
|
0d604f160d | ||
|
|
b352cb2d8e | ||
|
|
b9933b2ec1 | ||
|
|
f848a6f7f7 | ||
|
|
72eff6b2e9 | ||
|
|
56fc17be78 | ||
|
|
3c3e39684e | ||
|
|
25dfe9294f | ||
|
|
622404fcec | ||
|
|
bda02f4be8 | ||
|
|
58de6f91dc | ||
|
|
c0a5f15dc8 | ||
|
|
21b5f601b6 | ||
|
|
2e21158d04 | ||
|
|
16b510807b | ||
|
|
07c1245db4 | ||
|
|
d13c8b03c9 | ||
|
|
7b3104fe4c | ||
|
|
8fa4c4ff4e | ||
|
|
67ddc1a3e1 | ||
|
|
a17487bc9f | ||
|
|
f613f32b22 | ||
|
|
03415bb696 | ||
|
|
723b5085d9 | ||
|
|
28ffcf88bd | ||
|
|
7de025eacd | ||
|
|
1659b26151 | ||
|
|
c88178d9b6 | ||
|
|
117af11a6f | ||
|
|
b5bae67aad | ||
|
|
0fdc51f35d | ||
|
|
a1c6882777 | ||
|
|
59c84f8e5c | ||
|
|
d115fb4cf9 | ||
|
|
e9dee8dfe1 | ||
|
|
9f30af5a96 | ||
|
|
29b32050c1 | ||
|
|
815ffb3bb2 | ||
|
|
440e737c67 | ||
|
|
784fbcfd16 | ||
|
|
584fa3215c | ||
|
|
dc4f3b57cf | ||
|
|
985b41e136 | ||
|
|
51d0ef80c2 | ||
|
|
f870beac85 | ||
|
|
75de853c37 | ||
|
|
b3b962a051 | ||
|
|
6f3f4f7420 | ||
|
|
acb0e9c155 | ||
|
|
be1c4f3ee1 | ||
|
|
deb48a96fb | ||
|
|
086df266cc | ||
|
|
730aa406ef | ||
|
|
1a4eb0b5e7 | ||
|
|
4be8a58a7d | ||
|
|
a341ae27ec | ||
|
|
18f94fc83a | ||
|
|
aada44fca5 | ||
|
|
43658872d9 | ||
|
|
bd04d2db0d | ||
|
|
c8a733eae5 | ||
|
|
3e35f599bc | ||
|
|
914f313740 | ||
|
|
4efc48a80d | ||
|
|
ecc5601b2a | ||
|
|
14795dc0cc | ||
|
|
05dee6760d | ||
|
|
582aa1ceb2 | ||
|
|
f6e1bc393b | ||
|
|
c91d1048e4 | ||
|
|
90994a38a0 | ||
|
|
c01a0f5588 | ||
|
|
8ff61be8d6 | ||
|
|
90d569e896 | ||
|
|
d8bc71f222 | ||
|
|
f3ea2982f5 | ||
|
|
8f389de88f | ||
|
|
2bcba64906 | ||
|
|
cbd492d680 | ||
|
|
fadd275e7b | ||
|
|
35a3c064a7 | ||
|
|
91adfa1582 | ||
|
|
f3f85ae5f7 | ||
|
|
69550a9d3d | ||
|
|
5b8472b0b9 | ||
|
|
73dd36626c | ||
|
|
83905c9169 | ||
|
|
d92a0292a9 | ||
|
|
0e6937cc1b | ||
|
|
b1e5c9d7fa | ||
|
|
ba3eae5518 | ||
|
|
60673b03bc | ||
|
|
d5e8da8499 | ||
|
|
5f89fbe669 | ||
|
|
bc848b367f | ||
|
|
a6a99b923e | ||
|
|
ccad5d7b63 | ||
|
|
42b4715124 | ||
|
|
465c4cb580 | ||
|
|
37ccec0dc7 | ||
|
|
cb4d2e7bb9 | ||
|
|
41a92ae445 | ||
|
|
d7354d61b2 | ||
|
|
c57671176e | ||
|
|
44e31f7c6a | ||
|
|
63a06e312d | ||
|
|
ed9e9aab3d | ||
|
|
dfe99e9cd7 | ||
|
|
9331ac2cb0 | ||
|
|
7f28c8bd07 | ||
|
|
bafa6de76d | ||
|
|
6037a74660 | ||
|
|
f1235477de | ||
|
|
526925c509 | ||
|
|
3204efc195 | ||
|
|
2860da8cd5 | ||
|
|
8f2e520abb | ||
|
|
fe3f2bee3f | ||
|
|
a51e8a21b6 | ||
|
|
260e8e26fd | ||
|
|
196ea61ec4 | ||
|
|
49cc613021 | ||
|
|
347486a4c4 | ||
|
|
1517fe2c32 | ||
|
|
fe69df6b3a | ||
|
|
dac67b3978 | ||
|
|
a6c694da7e | ||
|
|
259d6aada8 | ||
|
|
de6aaf8e23 | ||
|
|
496e1e071f | ||
|
|
112939df60 | ||
|
|
e8cece82ef | ||
|
|
93c68c4432 | ||
|
|
2009bec87a | ||
|
|
f382a36458 | ||
|
|
45b12c0085 | ||
|
|
0b86591d9d | ||
|
|
1221414709 | ||
|
|
1c8de09ba9 | ||
|
|
7cd93f8e5c | ||
|
|
1dbde826f2 | ||
|
|
1d84255581 | ||
|
|
e1c88d4425 | ||
|
|
e69fedc8cf | ||
|
|
a841778b7b | ||
|
|
522d0f7ef5 | ||
|
|
50378c01e4 | ||
|
|
3416edf740 | ||
|
|
040f14b641 | ||
|
|
8c53d100ca | ||
|
|
5230a23202 | ||
|
|
6443d06764 | ||
|
|
6fd8cfd5bb | ||
|
|
95f9231136 | ||
|
|
e6b011823e | ||
|
|
31169ff3b4 | ||
|
|
7f09d6ae48 | ||
|
|
a7820b2f54 | ||
|
|
150673a734 | ||
|
|
b7e9272dbe | ||
|
|
0b86decf94 | ||
|
|
61e7b042b6 | ||
|
|
d10fd6b8f4 | ||
|
|
a509c48f0e | ||
|
|
057be10e5b | ||
|
|
b832975f3e | ||
|
|
26ea53cc68 | ||
|
|
57aec8c565 | ||
|
|
be6cac375a | ||
|
|
6e125adf3a | ||
|
|
0983e763fe | ||
|
|
69c3b56bde | ||
|
|
f66d14def5 | ||
|
|
eb7e237151 | ||
|
|
beb665212c | ||
|
|
689e8ec893 | ||
|
|
f8ad20b87e | ||
|
|
6897711d19 | ||
|
|
8ed5ea499d | ||
|
|
960117259d | ||
|
|
4b9a80d895 | ||
|
|
3b91d18c37 | ||
|
|
4f2dc09431 | ||
|
|
b3dc7a4a80 | ||
|
|
e2966faea7 | ||
|
|
b245cb2b6d | ||
|
|
2b15850b47 | ||
|
|
f10bad944f | ||
|
|
fb8b9e9138 | ||
|
|
e848671e9d | ||
|
|
b1719474d5 | ||
|
|
c8f5a2e0e2 | ||
|
|
c4e1bb30da | ||
|
|
1e2fda9e68 | ||
|
|
7d0347b6de | ||
|
|
a0c1f5962d | ||
|
|
33b81686ad | ||
|
|
07870dff45 | ||
|
|
99b24a80fb | ||
|
|
a39c2d784e | ||
|
|
0167f0a6df | ||
|
|
11e82bdef2 | ||
|
|
7aca070723 | ||
|
|
e5845dd452 | ||
|
|
ba55b3e360 | ||
|
|
467b068fdc | ||
|
|
18bfd44439 | ||
|
|
fb18f95348 | ||
|
|
7f4338d435 | ||
|
|
16cd7f9d3f | ||
|
|
4e2d9b0b76 | ||
|
|
040eba1cdc | ||
|
|
18d2bc441c | ||
|
|
75ef73d4f7 | ||
|
|
f440121a49 | ||
|
|
1ca7f5c0a0 | ||
|
|
61031d1b1c | ||
|
|
afa6b81120 | ||
|
|
4eeb7bfa57 | ||
|
|
aae13f4dd2 | ||
|
|
4305fb7cdf | ||
|
|
e8217cbb7a | ||
|
|
e3be541a6c | ||
|
|
b9d7dd4a84 | ||
|
|
6d362dbe9a | ||
|
|
1fd5a90894 | ||
|
|
bf3921dab7 | ||
|
|
c36b2bf64e | ||
|
|
04de01f8cf | ||
|
|
6811cee756 | ||
|
|
de455304cc | ||
|
|
f499841be6 | ||
|
|
9ad3ed481f | ||
|
|
604a6b5452 | ||
|
|
5e2c200d06 | ||
|
|
5620229f9f | ||
|
|
58c46ec03b | ||
|
|
4ef77dadec | ||
|
|
65b00716d2 | ||
|
|
1ec23446a0 | ||
|
|
d5d59eb1ea | ||
|
|
67faef0182 | ||
|
|
2106714f6b | ||
|
|
ece92bcbde | ||
|
|
95e898bf05 | ||
|
|
5a294cb2bd | ||
|
|
95ea4b7cc6 | ||
|
|
8eb03d81a0 | ||
|
|
a6df6838b9 | ||
|
|
ea487eb72c | ||
|
|
6e026fbb46 | ||
|
|
efffb42ef9 | ||
|
|
de83e9eb87 | ||
|
|
d9278c8efd | ||
|
|
355fb4d860 | ||
|
|
11ef611080 | ||
|
|
ba2dedb3bc | ||
|
|
8640b6aa7f | ||
|
|
5fff679aea | ||
|
|
c46ca5d638 | ||
|
|
40a9c38736 | ||
|
|
4d46098772 | ||
|
|
970df5f6e0 | ||
|
|
407ffdef0b | ||
|
|
98834defb0 | ||
|
|
bedfd4c200 | ||
|
|
22e2d1560f | ||
|
|
dc7bd4abf5 | ||
|
|
6c041ef65e | ||
|
|
e8628c6717 | ||
|
|
a397f53723 | ||
|
|
8c0aaee882 | ||
|
|
21bcc0e942 | ||
|
|
a5717c34ab | ||
|
|
39c5de484d | ||
|
|
7a750100c9 | ||
|
|
64e6ea0727 | ||
|
|
edc573daba | ||
|
|
b67679fb73 | ||
|
|
92fc7c5608 | ||
|
|
806b3b73bb | ||
|
|
91ecd9645f | ||
|
|
7ed17e3174 | ||
|
|
7dbc7702c3 | ||
|
|
3654ea32a9 | ||
|
|
fe329ffff0 | ||
|
|
7e8364f6d5 | ||
|
|
aa75477533 | ||
|
|
598e3f8e7b | ||
|
|
778f72f75b | ||
|
|
3d7df2bc07 | ||
|
|
e394e0f9b8 | ||
|
|
fb37811b65 | ||
|
|
f2ba23424e | ||
|
|
27e13933c0 | ||
|
|
ec1e27d562 | ||
|
|
ec0d3752ca | ||
|
|
fca7f220a7 | ||
|
|
e95fbc05aa | ||
|
|
cde6aff622 | ||
|
|
854be10e65 | ||
|
|
239523668e | ||
|
|
0fa034ed6d | ||
|
|
9ae38ac821 | ||
|
|
0902ee723b | ||
|
|
c093e4508d | ||
|
|
65f6e53e62 | ||
|
|
13926e622d | ||
|
|
5814f7e1d3 | ||
|
|
9945060c7d | ||
|
|
941329b2e5 | ||
|
|
209732535f | ||
|
|
47c578034a | ||
|
|
9e7110bb7d | ||
|
|
c82d7011b5 | ||
|
|
2bfc735050 | ||
|
|
ab84c8cc09 | ||
|
|
0aed1641c4 | ||
|
|
7652eda80c | ||
|
|
49ab52894a | ||
|
|
765477d77a | ||
|
|
b5f8191887 | ||
|
|
adca9a7523 | ||
|
|
a314a923bd | ||
|
|
80227005a0 | ||
|
|
4925f84219 | ||
|
|
b9d609edfe | ||
|
|
af3e354ff8 | ||
|
|
4ef63646d2 | ||
|
|
8a4573917d | ||
|
|
67697fa309 | ||
|
|
d9db23dc2f | ||
|
|
8b12be05ec | ||
|
|
b3c7ef6e62 | ||
|
|
80baa49342 | ||
|
|
a19225343b | ||
|
|
b022c6d770 | ||
|
|
82c0a60777 | ||
|
|
6361c46fe2 | ||
|
|
d879340ed9 | ||
|
|
39e3daa168 | ||
|
|
538b405fdd | ||
|
|
ac605a463a | ||
|
|
f17c472f26 | ||
|
|
9a1b5f9b68 | ||
|
|
f019e27c1d | ||
|
|
8f22632a29 | ||
|
|
7fb91317ba | ||
|
|
001da78fab | ||
|
|
5fbeffd56b | ||
|
|
3142c97c22 | ||
|
|
58e82d91ba | ||
|
|
b5bc752a48 | ||
|
|
2cb8ac1596 | ||
|
|
63d6bce324 | ||
|
|
d7e24e024f | ||
|
|
19c70e2a29 | ||
|
|
f8c60cb9b7 | ||
|
|
6235720c8a | ||
|
|
93e15abdf6 | ||
|
|
4ad9478d68 | ||
|
|
58e52e9424 | ||
|
|
7d5dd8aad2 | ||
|
|
476d0a2c4b | ||
|
|
468b971fba | ||
|
|
f712bbcb3f | ||
|
|
62c6f4480e | ||
|
|
2644f26a35 | ||
|
|
2be1d1b9f9 | ||
|
|
2cd0c8b515 | ||
|
|
a18bc56996 | ||
|
|
e01b04d48a | ||
|
|
351d056ca6 | ||
|
|
e098fd40ac | ||
|
|
150296261e | ||
|
|
36b0b12971 | ||
|
|
151f3a4cec | ||
|
|
c8334ad0eb | ||
|
|
9366d0a873 | ||
|
|
e2794cdf65 | ||
|
|
5102e0cabe | ||
|
|
615199a6a4 | ||
|
|
91a4c594d8 | ||
|
|
79691d4858 | ||
|
|
6a2ccbc929 | ||
|
|
4b6517d114 | ||
|
|
3dfb76f13b | ||
|
|
1951413a0b | ||
|
|
92c1547a89 | ||
|
|
95bf36fe28 | ||
|
|
0d382d7823 | ||
|
|
ebb1615676 | ||
|
|
cb765f1664 | ||
|
|
4c4e8a213f | ||
|
|
721cedfbf0 | ||
|
|
4438be7f05 | ||
|
|
c4a5bba800 | ||
|
|
d1fad163d9 | ||
|
|
2799e6c910 | ||
|
|
d095d1663b | ||
|
|
ed59629ccd | ||
|
|
4a206db106 | ||
|
|
1042dce454 | ||
|
|
9996cad49a | ||
|
|
68d0c0f2f5 | ||
|
|
529ea02353 | ||
|
|
18641831bf | ||
|
|
1b138d3f38 | ||
|
|
ba2620a9af | ||
|
|
182d60535a | ||
|
|
28a2043f51 | ||
|
|
036298fbae | ||
|
|
3eca409456 | ||
|
|
3430a2d26f | ||
|
|
888cd08fa8 | ||
|
|
fbf900c746 | ||
|
|
0ad43bbf3d | ||
|
|
7a803c113d | ||
|
|
aff6d079d3 | ||
|
|
bba28df9f7 | ||
|
|
9f28e8c5f4 | ||
|
|
27eb8732d3 | ||
|
|
1d645ff66b | ||
|
|
ab3eca14f1 | ||
|
|
7e59e43ce6 | ||
|
|
e7e3b4a58b | ||
|
|
ad038c87e8 | ||
|
|
83f006a11d | ||
|
|
e961803332 | ||
|
|
717bfb4031 | ||
|
|
8477e39db7 | ||
|
|
d5c98696a0 | ||
|
|
ff9df09e53 | ||
|
|
a631f5ff26 | ||
|
|
c3a073769f | ||
|
|
3ab0e78028 | ||
|
|
e1f64a0dd0 | ||
|
|
eb3dc18b13 | ||
|
|
9f9067f559 | ||
|
|
853b7cc75d | ||
|
|
0cdb80078f | ||
|
|
63111746b1 | ||
|
|
024cd0e4aa | ||
|
|
8a04851fa0 | ||
|
|
86ed25af34 | ||
|
|
ed62aefeee | ||
|
|
7708e8c7ef | ||
|
|
82a16d2fee | ||
|
|
656c238295 | ||
|
|
e890d7ea4f | ||
|
|
7d76e54f2b | ||
|
|
8ac0c35462 | ||
|
|
49807ac1f1 | ||
|
|
0b84d8b521 | ||
|
|
75c011b606 | ||
|
|
c7127c7c34 | ||
|
|
f634062f35 | ||
|
|
c3e02d9fd4 | ||
|
|
837d6a13a2 | ||
|
|
57a3dbe736 | ||
|
|
97afdc144d | ||
|
|
9bf48660b3 | ||
|
|
966c274f20 | ||
|
|
173a21f557 | ||
|
|
6950e85605 | ||
|
|
30c1ca5c7b | ||
|
|
274a8116af | ||
|
|
5871d118ad | ||
|
|
c951867a21 | ||
|
|
18f9310844 | ||
|
|
d506e9e666 | ||
|
|
9e002c12ac | ||
|
|
bee163bf37 | ||
|
|
5fce8cef1e | ||
|
|
b620c58e65 | ||
|
|
2e015ab124 | ||
|
|
5518ac998f | ||
|
|
850f7c24d4 | ||
|
|
17907bc2cd | ||
|
|
25b3c8ef71 | ||
|
|
f4c6c0aec4 | ||
|
|
03ac6e3171 | ||
|
|
00ca654c74 | ||
|
|
8201e851ca | ||
|
|
e144d1c8d9 | ||
|
|
fb6f2c61bf | ||
|
|
def11c0978 | ||
|
|
628104662b | ||
|
|
d4a17477b0 | ||
|
|
b78ebacb18 | ||
|
|
189a7962b2 | ||
|
|
d18ee1881c | ||
|
|
5ff0c75da7 | ||
|
|
f6d293a1ee | ||
|
|
ee6eab8143 | ||
|
|
843577f69a | ||
|
|
a4bb9b1438 | ||
|
|
895d1a90f3 | ||
|
|
456cade93c | ||
|
|
2990c00cb5 | ||
|
|
35cdd40182 | ||
|
|
9497629c1e | ||
|
|
e5063f51cb | ||
|
|
3e050d05e8 | ||
|
|
8363d6596c | ||
|
|
6fab00acaa | ||
|
|
24614ac100 | ||
|
|
6c309b9883 | ||
|
|
2ea8d88d63 | ||
|
|
0fbd975fe8 | ||
|
|
ac52499aca | ||
|
|
4ad875308f | ||
|
|
c48a4a3188 | ||
|
|
9de6abd8d7 | ||
|
|
c7f50738c0 | ||
|
|
dca86d47e0 | ||
|
|
854cb9292d | ||
|
|
fce7470495 | ||
|
|
0b24f47465 | ||
|
|
4fae13e29e | ||
|
|
0bc591a7d7 | ||
|
|
0a14f593c3 | ||
|
|
c9a939ad2d | ||
|
|
286883cc54 | ||
|
|
b0730944eb | ||
|
|
d78b0814d5 | ||
|
|
2879f76301 | ||
|
|
13ac8a0758 | ||
|
|
31f3914082 | ||
|
|
8e56c024df | ||
|
|
70230f4235 | ||
|
|
44adda3195 | ||
|
|
4829d30cf0 | ||
|
|
7a381b807e | ||
|
|
c559776c51 | ||
|
|
f9b1132bbb | ||
|
|
85b6f91bd7 | ||
|
|
0f0c744517 | ||
|
|
91a78477d0 | ||
|
|
625b793635 | ||
|
|
aa53823981 | ||
|
|
b474f429ee | ||
|
|
8d63d466b8 | ||
|
|
05ff7d374f | ||
|
|
08beb6b0e8 | ||
|
|
8124fb4aa4 | ||
|
|
f212176e91 | ||
|
|
611adb2ee0 | ||
|
|
667c03f87e | ||
|
|
fa9901c78f | ||
|
|
ed36f423da | ||
|
|
2e042fbca8 | ||
|
|
cdeafd1895 | ||
|
|
621db8f0b1 | ||
|
|
f5e1fe9755 | ||
|
|
e9d49299d6 | ||
|
|
00e4d54e1f | ||
|
|
31627d0808 | ||
|
|
6bf2fdf739 | ||
|
|
fa1d5f6584 | ||
|
|
1e5ccd1ce8 | ||
|
|
b43910b590 | ||
|
|
6fdf6b0680 | ||
|
|
13cb9f8277 | ||
|
|
8eb5ff08c8 | ||
|
|
309fdd95da | ||
|
|
1188aa3b81 | ||
|
|
98611e6272 | ||
|
|
5fb83af3e3 | ||
|
|
0e86ca1352 | ||
|
|
22e8cd2a1d | ||
|
|
7979639cd8 | ||
|
|
8ada0f4ae2 | ||
|
|
1d11178d02 | ||
|
|
935f84b8e9 | ||
|
|
141b0b3afb | ||
|
|
3c5f5efc8c | ||
|
|
59cec74d89 | ||
|
|
0f72a042d6 | ||
|
|
1e48ca4e32 | ||
|
|
4b147f2c2e | ||
|
|
8a60f39221 | ||
|
|
4638f58615 | ||
|
|
c7144a8689 | ||
|
|
4dd3ba149c | ||
|
|
30c24bba97 | ||
|
|
27cd18748f | ||
|
|
21b33bd04d | ||
|
|
2fef80aee5 | ||
|
|
25a5cb3270 | ||
|
|
f3cfd752d3 | ||
|
|
15772c527a | ||
|
|
846ca1e5bd | ||
|
|
dc0d833efc | ||
|
|
9596b7bd7a | ||
|
|
adabff1bf0 | ||
|
|
0bacc93208 | ||
|
|
dac13d9a69 | ||
|
|
af64a824a1 | ||
|
|
c037ab5c74 | ||
|
|
58149e41dc | ||
|
|
00c9f81171 | ||
|
|
3c8ad8cbaa | ||
|
|
b2bdad5bee | ||
|
|
27b15a19e8 | ||
|
|
398b98dcbe | ||
|
|
9ec4e94c48 | ||
|
|
18ef59bb33 | ||
|
|
e7fb8cabb6 | ||
|
|
6f9d5e1b95 | ||
|
|
2209faef40 | ||
|
|
4b18234fc1 | ||
|
|
bf30361bc8 | ||
|
|
cb085ec5f1 | ||
|
|
5a6472718d | ||
|
|
fd643139b1 | ||
|
|
d8f2437cf4 | ||
|
|
ffd4a80145 | ||
|
|
4df1fcf7b3 | ||
|
|
5c7f960125 | ||
|
|
5a84869c06 | ||
|
|
bca6a91fc4 | ||
|
|
059bed7731 | ||
|
|
d4543ac8e4 | ||
|
|
fae58591cd | ||
|
|
d560588e1e | ||
|
|
1c0b8f6a6b | ||
|
|
5f301e09ea | ||
|
|
f2dfb67f2c | ||
|
|
01d9963e4e | ||
|
|
c237de552a | ||
|
|
3cf9877d0c | ||
|
|
d503ec52d8 | ||
|
|
c91cbf3f71 | ||
|
|
b012ae46aa | ||
|
|
ee3efc0152 | ||
|
|
45892a6595 | ||
|
|
9d84a13bb8 | ||
|
|
9dd3bce549 | ||
|
|
c8cc010e09 | ||
|
|
6e25112aad | ||
|
|
a4ff3e19ea | ||
|
|
9ca791288c | ||
|
|
564ccf1faa | ||
|
|
47e86bc1ac | ||
|
|
7f6579e416 | ||
|
|
19d9e71b84 | ||
|
|
dbf711c2ea | ||
|
|
c7a1e909a3 | ||
|
|
fce00ccb6e | ||
|
|
2f8b1a8c0e | ||
|
|
51b5f75b92 | ||
|
|
94db48d028 | ||
|
|
2dcca3ec8a | ||
|
|
91df558e69 | ||
|
|
6f2add2cc6 | ||
|
|
bb2254520d | ||
|
|
d5bbf3033c | ||
|
|
c36ba9ea7a | ||
|
|
185e62a9ae | ||
|
|
66bf324256 | ||
|
|
0d189102f5 | ||
|
|
60392a1136 | ||
|
|
d7aa368776 | ||
|
|
025e6ac31d | ||
|
|
f5cb6177e4 | ||
|
|
c3e629cbf4 | ||
|
|
edda0608ac | ||
|
|
b425438a58 | ||
|
|
c0094a232d | ||
|
|
d6c76eb5bf | ||
|
|
d33c2eefce | ||
|
|
d2fbc8c0e7 | ||
|
|
4835a7ecd9 | ||
|
|
b779bdb5a0 | ||
|
|
a087dbd9e9 | ||
|
|
6bdaada782 | ||
|
|
417b6e72c4 | ||
|
|
14ce8733fe | ||
|
|
9813ff2f0a | ||
|
|
9b692f0a5b | ||
|
|
4ac6bb1964 | ||
|
|
e0aa820257 | ||
|
|
fe76bae1ed | ||
|
|
b1117d9862 | ||
|
|
fd8353012f | ||
|
|
c0d525c8a0 | ||
|
|
c66c404d58 | ||
|
|
10a3417bd3 | ||
|
|
c8f2bbf76d | ||
|
|
efc93bf282 | ||
|
|
92051f6746 | ||
|
|
73cf516def | ||
|
|
93c30de17b | ||
|
|
00067563a6 | ||
|
|
3aae25358e | ||
|
|
5d8cf28578 | ||
|
|
99bd275359 | ||
|
|
d4021d1d54 | ||
|
|
f927e532da | ||
|
|
0ae1ac17ea | ||
|
|
37c5003ed9 | ||
|
|
ca41fa293f | ||
|
|
7bf100b028 | ||
|
|
f7b0b5429e | ||
|
|
8bd4736f03 | ||
|
|
27d1c08c51 | ||
|
|
b998a921c7 | ||
|
|
61cf005437 | ||
|
|
61c538e2fc | ||
|
|
f09b69a78f | ||
|
|
091e15139b | ||
|
|
c903b271cf | ||
|
|
5cccfe1c17 | ||
|
|
7e04680e23 | ||
|
|
3a20a0cd4f | ||
|
|
1901b832eb | ||
|
|
5869131eea | ||
|
|
101cb70844 | ||
|
|
913241ebf9 | ||
|
|
e12a6d6a67 | ||
|
|
c3ff31e770 | ||
|
|
7bcef07297 | ||
|
|
ecbb5cd9b6 | ||
|
|
ffd517b513 | ||
|
|
5685238656 | ||
|
|
f8d63f4b24 | ||
|
|
592277cd77 | ||
|
|
fc8b57e0cf | ||
|
|
92a405b536 | ||
|
|
c4e4d122e9 | ||
|
|
9119e8d99c | ||
|
|
f9f4c4959b | ||
|
|
d264119c75 | ||
|
|
a92eb02ec3 | ||
|
|
f77a2687b6 | ||
|
|
661a9ba559 | ||
|
|
068e02684b | ||
|
|
3cb4f33d3c | ||
|
|
0296f0a779 | ||
|
|
49f36ab58d | ||
|
|
28597d2790 | ||
|
|
72de534a93 | ||
|
|
7c16af4933 | ||
|
|
0e40408375 | ||
|
|
9a4aa438bb | ||
|
|
f2843d3d79 | ||
|
|
d7fca5794d | ||
|
|
4c49ca75d9 | ||
|
|
82cb02a4fd | ||
|
|
30de7874cf | ||
|
|
2ba725ef48 | ||
|
|
36de671cad | ||
|
|
aeeccdf27f | ||
|
|
46546e6817 | ||
|
|
6a65cc5e9c | ||
|
|
b4f03c2e64 | ||
|
|
38fd443677 | ||
|
|
3c907250b9 | ||
|
|
1211123fe6 | ||
|
|
e9cca2d1ef | ||
|
|
1610b4983f | ||
|
|
13c1aa7fb9 | ||
|
|
8a8767dd1e | ||
|
|
9dd8ffd767 | ||
|
|
46a67eea4c | ||
|
|
361753908e | ||
|
|
56a5d7e865 | ||
|
|
44dc29f397 | ||
|
|
3029326a56 | ||
|
|
5990524c5f | ||
|
|
b240ce2085 | ||
|
|
e32a59bc79 | ||
|
|
ac8c56cc70 | ||
|
|
201fe25dad | ||
|
|
74d5aeae1a | ||
|
|
7932a4aa74 | ||
|
|
6d90e00fa3 | ||
|
|
b0e9569ebd | ||
|
|
444dd19a28 | ||
|
|
59d4327698 | ||
|
|
9a7014ac38 | ||
|
|
7b8ec95108 | ||
|
|
8176bc8a76 | ||
|
|
66d71238a8 | ||
|
|
b21b105752 | ||
|
|
b877fc58a5 | ||
|
|
359c31b7e7 | ||
|
|
86d7beab99 | ||
|
|
365f551f9d | ||
|
|
278d04aa4b | ||
|
|
9184b096bf | ||
|
|
4491232874 | ||
|
|
11ef608685 | ||
|
|
ff21b4e731 | ||
|
|
2df9b2e8ea | ||
|
|
7ebd600297 | ||
|
|
bd77ebc761 | ||
|
|
7be08d0376 | ||
|
|
f5c7d77fb0 | ||
|
|
3ca1135515 | ||
|
|
80f7e36ddc | ||
|
|
8e806e9125 | ||
|
|
f77e09f78e | ||
|
|
4287cd2e6e | ||
|
|
ac05545dba | ||
|
|
aa09f44b47 | ||
|
|
ef7ad6f744 | ||
|
|
39db00f896 | ||
|
|
423531df50 | ||
|
|
cb790f77da | ||
|
|
938b2a84dd | ||
|
|
04c2982535 | ||
|
|
912e9dd0a6 | ||
|
|
1fd73947d1 | ||
|
|
4dbe2a3d2b | ||
|
|
1e913580d4 | ||
|
|
dcecda5596 | ||
|
|
b8311ad6ea | ||
|
|
bab9a8dc37 | ||
|
|
6d39b94a7b | ||
|
|
e69855e68c | ||
|
|
2bc3c7ad5a | ||
|
|
0dbdaf98ea | ||
|
|
59997d8689 | ||
|
|
a2d386638c | ||
|
|
563ad77d13 | ||
|
|
b05aefa3cf | ||
|
|
fc6fd9aa36 | ||
|
|
769de93f9c | ||
|
|
e04158a028 | ||
|
|
8a007c987d | ||
|
|
51b69497a3 | ||
|
|
c84d53ccfe | ||
|
|
d9452e6acb | ||
|
|
ee6f26406f | ||
|
|
4512363e85 | ||
|
|
a5241782ca | ||
|
|
8a76cc3470 | ||
|
|
22515eea44 | ||
|
|
017e241162 | ||
|
|
09c3768cdd | ||
|
|
f0207d3ea0 | ||
|
|
b5202f975b | ||
|
|
516be11db9 | ||
|
|
3807a01542 | ||
|
|
1d965d9a6f | ||
|
|
ec4a00beae | ||
|
|
3533297cd9 | ||
|
|
db66004b31 | ||
|
|
2b69cfe030 | ||
|
|
0c716d7717 | ||
|
|
9a21e4e6c2 | ||
|
|
b5d90ae4ec | ||
|
|
b3fbe5325e | ||
|
|
607e6c206f | ||
|
|
48291462ef | ||
|
|
ccf3476a4a | ||
|
|
6966c202b9 | ||
|
|
205d6b730f | ||
|
|
a661506b0f | ||
|
|
2be9eb1e97 | ||
|
|
714ff554fd | ||
|
|
b1c95a82a0 | ||
|
|
9268f9fe8a | ||
|
|
90c2ac3b6a | ||
|
|
f49a3e4c26 | ||
|
|
4cbce8458d | ||
|
|
80c50c2370 | ||
|
|
898f74c27e | ||
|
|
a8dec44f56 | ||
|
|
2267ddc3a0 | ||
|
|
05202c1f8a | ||
|
|
9803261f71 | ||
|
|
5e68d2f811 | ||
|
|
6e3f38d033 | ||
|
|
e85231d63d | ||
|
|
686751f639 | ||
|
|
f7507fd921 | ||
|
|
ea682182d0 | ||
|
|
2b587be44d | ||
|
|
50e6bd307d | ||
|
|
7bc871139d | ||
|
|
c629270f23 | ||
|
|
3dee915b3b | ||
|
|
09c5b2dd37 | ||
|
|
59205bd63c | ||
|
|
6fd4aa8a27 | ||
|
|
409356fc66 | ||
|
|
c0946e6e58 | ||
|
|
fcbc254d0d | ||
|
|
9cb4e48018 | ||
|
|
46f023d097 | ||
|
|
8b180fe829 | ||
|
|
b84078a975 | ||
|
|
1dcb677985 | ||
|
|
c42664f9b2 | ||
|
|
04a6fd7fde | ||
|
|
483b06fb86 | ||
|
|
d487c58c6f | ||
|
|
1e67387475 | ||
|
|
8ed9330a30 | ||
|
|
605e2976ed | ||
|
|
8fbdfc0a76 | ||
|
|
503d8d5542 | ||
|
|
f99259d25c | ||
|
|
ec8ff27803 | ||
|
|
afb56ea972 | ||
|
|
3617247c65 | ||
|
|
376b03f8ea | ||
|
|
4d5b317ace | ||
|
|
396a8ef6f8 | ||
|
|
5eed10fd6e | ||
|
|
76130fd988 | ||
|
|
73168d37ac | ||
|
|
41366d3f51 | ||
|
|
bd773d2f61 | ||
|
|
2a5a9fd720 | ||
|
|
4fb904ca63 | ||
|
|
dfe9774387 | ||
|
|
663cf97bea | ||
|
|
7c4601ec73 | ||
|
|
588078224b | ||
|
|
a0fcb91670 | ||
|
|
2d8cebba5c | ||
|
|
1ac8c71cf5 | ||
|
|
9f0fccd3a5 | ||
|
|
490c226202 | ||
|
|
a2595f16d4 | ||
|
|
1a926d19b0 | ||
|
|
d23e4111b0 | ||
|
|
a691d52329 | ||
|
|
f9834a3f95 | ||
|
|
43e243f436 | ||
|
|
4b8c260444 | ||
|
|
b3db1dba85 | ||
|
|
0786f586af | ||
|
|
f0bfa650dc | ||
|
|
6fbdae1c51 | ||
|
|
e8f29087ae | ||
|
|
b2fdbc53e8 | ||
|
|
528371e7a4 | ||
|
|
5e33d7dff9 | ||
|
|
cd80b4efca | ||
|
|
68ff0b9881 | ||
|
|
8ec4a72f64 | ||
|
|
8338986a59 | ||
|
|
d23e4aea6f | ||
|
|
a82dfb8e58 | ||
|
|
2afff85ca4 | ||
|
|
b87510957f | ||
|
|
4ad9f0bdbb | ||
|
|
2305bca782 | ||
|
|
bcf354eac1 | ||
|
|
21e69fdd4f | ||
|
|
7859ee396e | ||
|
|
5eee488d93 | ||
|
|
1d28dd87a5 | ||
|
|
a8991e02d8 | ||
|
|
99f70284bf | ||
|
|
21db3ff11c | ||
|
|
19d1c217dc | ||
|
|
492105db5a | ||
|
|
d3b5413a01 | ||
|
|
2e8b3445fb | ||
|
|
339a74a342 | ||
|
|
5b79ab0901 | ||
|
|
929b3a4f16 | ||
|
|
2cde331772 | ||
|
|
5f9d71f8af | ||
|
|
df4475d232 | ||
|
|
f90e266416 | ||
|
|
bbc9a7d3fa | ||
|
|
d47eee4407 | ||
|
|
2122dccb91 | ||
|
|
d08bcb427e | ||
|
|
42688f5aae | ||
|
|
d6c8e05de9 | ||
|
|
ca87241289 | ||
|
|
ed9299a216 | ||
|
|
3bf86877c2 | ||
|
|
202ccf4cf7 | ||
|
|
27dfb9149f | ||
|
|
b2fc8af1b1 | ||
|
|
f3a23f8f5d | ||
|
|
c2c29588f4 | ||
|
|
0311171350 | ||
|
|
5393240441 | ||
|
|
5ebf3b0396 | ||
|
|
ea0b6bcb1f | ||
|
|
5fc5aa8f81 | ||
|
|
a23a668d91 | ||
|
|
e205888fa7 | ||
|
|
53475c21b8 | ||
|
|
9e1faf81ab | ||
|
|
f4f059ef94 | ||
|
|
75c3b53038 | ||
|
|
478e0ec3f8 | ||
|
|
51e240123b | ||
|
|
e9655b9fdc | ||
|
|
0d66710539 | ||
|
|
2df8021cda | ||
|
|
cd0b692b61 | ||
|
|
716fd67e03 | ||
|
|
a85ff92c05 | ||
|
|
96635c7c27 | ||
|
|
c00ac952a8 | ||
|
|
982e2cf0ef | ||
|
|
03e6181f9f | ||
|
|
5f88932806 | ||
|
|
fb80d3a491 | ||
|
|
e67ff0c43e | ||
|
|
c9c53e3153 | ||
|
|
7a36bb37af | ||
|
|
b261e9e6dd | ||
|
|
e707b452c0 | ||
|
|
79e733cc34 | ||
|
|
f8c8c0d41e | ||
|
|
8dc9cfe734 | ||
|
|
e5adde9fe3 | ||
|
|
ad3e3cb7d2 | ||
|
|
5216841a9e | ||
|
|
b601550c97 | ||
|
|
ad1d8bf990 | ||
|
|
049c1158c9 | ||
|
|
81c90aab6b | ||
|
|
85277c2db1 | ||
|
|
9adbab05c6 | ||
|
|
83bb5fb994 | ||
|
|
b6ef874220 | ||
|
|
68e6f03fd9 | ||
|
|
7b5f0c23e5 | ||
|
|
3e2994b975 | ||
|
|
2c3d7f5bad | ||
|
|
dab3152e0e | ||
|
|
3fea219692 | ||
|
|
3f3ed5ec66 | ||
|
|
f6e51ff99a | ||
|
|
65d47dc5d7 | ||
|
|
b4741302c6 | ||
|
|
76f447b250 | ||
|
|
bc6ecc89d5 | ||
|
|
47fdd6b88b | ||
|
|
80909b3265 | ||
|
|
c7891ec67e | ||
|
|
910354b07f | ||
|
|
844d263af0 | ||
|
|
27d57af127 | ||
|
|
b667bdd622 | ||
|
|
3cb7ae5350 | ||
|
|
b58786ce9f | ||
|
|
ff5886bba2 | ||
|
|
f2f18f5958 | ||
|
|
8ba71e4aff | ||
|
|
44451eaa47 | ||
|
|
865678eb6b | ||
|
|
38f3040384 | ||
|
|
bda924b639 | ||
|
|
8677310fb5 | ||
|
|
e856932600 | ||
|
|
a048cbc4f0 | ||
|
|
8e3be0a705 | ||
|
|
76ebc14956 | ||
|
|
3d89f493ba | ||
|
|
a5eddb91bb | ||
|
|
56302f79a8 | ||
|
|
dc31f73b39 | ||
|
|
5809bdf0cb | ||
|
|
97ed582f1c | ||
|
|
6eedc8331b | ||
|
|
6835f05cd0 | ||
|
|
d7e62a87f2 | ||
|
|
f48a89cb1c | ||
|
|
aa82b43c9f | ||
|
|
a854331c4c | ||
|
|
3fb67467fa | ||
|
|
51e57d46cf | ||
|
|
e5a687f115 | ||
|
|
17c1b06cc7 | ||
|
|
bda3531560 | ||
|
|
aab5410bd5 | ||
|
|
e0635eb6fd | ||
|
|
4252f07ff0 | ||
|
|
4ce3c3e36c | ||
|
|
653e8d1ea5 | ||
|
|
98d6331d10 | ||
|
|
2b0e399da1 | ||
|
|
b234aa0085 | ||
|
|
cee364e2f6 | ||
|
|
da551463e3 | ||
|
|
2252cf6f03 | ||
|
|
9a7f808953 | ||
|
|
eb273a8a4a | ||
|
|
259796dc3d | ||
|
|
d64b394537 | ||
|
|
88c395c83c | ||
|
|
9085d17ab6 | ||
|
|
4a2b02e86f | ||
|
|
beb25d60f7 | ||
|
|
4bd711e1c4 | ||
|
|
3844e035bb | ||
|
|
9fef53c3b1 | ||
|
|
91a4635bdc | ||
|
|
629fc2f8f0 |
@@ -100,6 +100,10 @@ Format first if formatting can change line locations. Then it is OK to run tests
|
||||
scripts/autoreview --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
On Windows, the default `--parallel-tests` shell preserves the platform `cmd.exe`
|
||||
semantics used by Python `shell=True`. Use `--parallel-tests-shell powershell`
|
||||
or `--parallel-tests-shell pwsh` when the focused test command is PowerShell-specific.
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
|
||||
|
||||
## Review Panels
|
||||
@@ -144,6 +148,22 @@ OpenClaw repo-local helper:
|
||||
.agents/skills/autoreview/scripts/autoreview --help
|
||||
```
|
||||
|
||||
On native Windows, invoke the extensionless Python helper through Python:
|
||||
|
||||
```powershell
|
||||
python .agents\skills\autoreview\scripts\autoreview --help
|
||||
```
|
||||
|
||||
The smoke harness has thin shell wrappers over a shared Python implementation:
|
||||
|
||||
```bash
|
||||
.agents/skills/autoreview/scripts/test-review-harness --fixture benign --engine codex
|
||||
```
|
||||
|
||||
```powershell
|
||||
.agents\skills\autoreview\scripts\test-review-harness.ps1 -Fixture benign -Engine codex
|
||||
```
|
||||
|
||||
`agent-scripts` checkout helper:
|
||||
|
||||
```bash
|
||||
@@ -169,10 +189,11 @@ The helper:
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
|
||||
- resolves bare `git`, `gh`, reviewer, and PowerShell shell commands from absolute `PATH` entries only, never from the reviewed checkout; explicit relative `--*-bin` paths are resolved from the reviewed repository root
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
|
||||
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports `--dry-run`, `--parallel-tests`, `--parallel-tests-shell`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
|
||||
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
|
||||
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
|
||||
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
|
||||
|
||||
@@ -214,12 +214,17 @@ def run_with_stream(
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, check: bool = True) -> str:
|
||||
return run(["git", *args], repo, check=check).stdout
|
||||
return run([resolve_command("git", repo), *args], repo, check=check).stdout
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
start = Path.cwd().resolve()
|
||||
unsafe_root = discover_repo_root(start) or start
|
||||
git_bin = find_command("git", unsafe_root)
|
||||
if not git_bin:
|
||||
raise SystemExit("git executable not found. Install Git or add it to PATH.")
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
[git_bin, "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -229,6 +234,16 @@ def repo_root() -> Path:
|
||||
return Path(result.stdout.strip()).resolve()
|
||||
|
||||
|
||||
def discover_repo_root(start: Path) -> Path | None:
|
||||
current = start
|
||||
while True:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def current_branch(repo: Path) -> str:
|
||||
return git(repo, "branch", "--show-current", check=False).strip() or "detached"
|
||||
|
||||
@@ -250,17 +265,70 @@ def choose_target(repo: Path, mode: str, base_ref: str | None) -> tuple[str, str
|
||||
|
||||
|
||||
def detect_pr_base(repo: Path) -> str | None:
|
||||
if not shutil_which("gh"):
|
||||
gh_bin = find_command("gh", repo)
|
||||
if not gh_bin:
|
||||
return None
|
||||
result = run(["gh", "pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], repo, check=False)
|
||||
result = run([gh_bin, "pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], repo, check=False)
|
||||
base = result.stdout.strip()
|
||||
return f"origin/{base}" if result.returncode == 0 and base else None
|
||||
|
||||
|
||||
def shutil_which(name: str) -> str | None:
|
||||
def resolve_command(name: str, repo: Path) -> str:
|
||||
resolved = find_command(name, repo)
|
||||
if resolved:
|
||||
return resolved
|
||||
raise SystemExit(f"executable not found: {name}. Install it or pass an explicit trusted path when supported.")
|
||||
|
||||
|
||||
def find_command(name: str, repo: Path) -> str | None:
|
||||
command = Path(name)
|
||||
if has_directory_component(name, command):
|
||||
base = command if command.is_absolute() else repo / command
|
||||
return first_executable_candidate(base)
|
||||
for part in os.environ.get("PATH", "").split(os.pathsep):
|
||||
candidate = Path(part) / name
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
if not part or part == ".":
|
||||
continue
|
||||
path_part = Path(part)
|
||||
if not path_part.is_absolute():
|
||||
continue
|
||||
try:
|
||||
resolved_part = path_part.resolve()
|
||||
resolved_repo = repo.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if is_within(resolved_part, resolved_repo):
|
||||
continue
|
||||
found = first_executable_candidate(resolved_part / name, reject_root=resolved_repo)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def is_within(path: Path, root: Path) -> bool:
|
||||
return path == root or path.is_relative_to(root)
|
||||
|
||||
|
||||
def has_directory_component(name: str, command: Path) -> bool:
|
||||
separators = [separator for separator in (os.sep, os.altsep) if separator]
|
||||
return command.is_absolute() or bool(command.drive) or any(separator in name for separator in separators)
|
||||
|
||||
|
||||
def first_executable_candidate(path: Path, *, reject_root: Path | None = None) -> str | None:
|
||||
if os.name == "nt" and not path.suffix:
|
||||
extensions = [ext for ext in os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") if ext]
|
||||
candidates = [path.with_suffix(ext.lower()) for ext in extensions]
|
||||
candidates.extend(path.with_suffix(ext.upper()) for ext in extensions)
|
||||
candidates.append(path)
|
||||
else:
|
||||
candidates = [path]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
if reject_root is not None:
|
||||
try:
|
||||
if is_within(candidate.resolve(), reject_root):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
@@ -419,7 +487,7 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
raise SystemExit("--no-tools is not supported by the Codex engine; use --engine claude --no-tools for a no-tools run")
|
||||
schema_path = write_json_temp(SCHEMA)
|
||||
output_path = Path(tempfile.NamedTemporaryFile("w", suffix=".json", delete=False).name)
|
||||
cmd = [args.codex_bin, "--ask-for-approval", "never"]
|
||||
cmd = [resolve_command(args.codex_bin, repo), "--ask-for-approval", "never"]
|
||||
if args.web_search:
|
||||
cmd.append("--search")
|
||||
if args.model:
|
||||
@@ -463,7 +531,7 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
|
||||
def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
cmd = [
|
||||
args.claude_bin,
|
||||
resolve_command(args.claude_bin, repo),
|
||||
"--print",
|
||||
"--no-session-persistence",
|
||||
"--output-format",
|
||||
@@ -500,7 +568,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
prompt_path = Path(tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False).name)
|
||||
prompt_path.write_text(prompt)
|
||||
cmd = [
|
||||
args.droid_bin,
|
||||
resolve_command(args.droid_bin, repo),
|
||||
"exec",
|
||||
"--cwd",
|
||||
str(repo),
|
||||
@@ -530,7 +598,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
|
||||
prompt_path.write_text(prompt)
|
||||
os.chmod(prompt_path, 0o600)
|
||||
cmd = [
|
||||
args.copilot_bin,
|
||||
resolve_command(args.copilot_bin, repo),
|
||||
"-C",
|
||||
tempdir,
|
||||
"-p",
|
||||
@@ -877,9 +945,23 @@ def print_report(report: dict[str, Any], *, label: str = "autoreview") -> None:
|
||||
print(report["overall_explanation"])
|
||||
|
||||
|
||||
def start_parallel_tests(command: str, repo: Path) -> tuple[subprocess.Popen, float]:
|
||||
def start_parallel_tests(command: str, repo: Path, shell_kind: str) -> tuple[subprocess.Popen, float]:
|
||||
print(f"tests: {command}")
|
||||
return subprocess.Popen(command, cwd=repo, shell=True), time.time()
|
||||
if shell_kind == "default" or shell_kind == "cmd":
|
||||
return subprocess.Popen(command, cwd=repo, shell=True), time.time()
|
||||
if shell_kind == "powershell":
|
||||
powershell = resolve_command("powershell", repo)
|
||||
return subprocess.Popen(
|
||||
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
|
||||
cwd=repo,
|
||||
), time.time()
|
||||
if shell_kind == "pwsh":
|
||||
pwsh = resolve_command("pwsh", repo)
|
||||
return subprocess.Popen(
|
||||
[pwsh, "-NoProfile", "-Command", command],
|
||||
cwd=repo,
|
||||
), time.time()
|
||||
raise SystemExit(f"invalid --parallel-tests-shell/AUTOREVIEW_PARALLEL_TESTS_SHELL: {shell_kind}")
|
||||
|
||||
|
||||
def finish_parallel_tests(proc: subprocess.Popen, started: float) -> int:
|
||||
@@ -924,6 +1006,12 @@ def parse_args() -> argparse.Namespace:
|
||||
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
|
||||
)
|
||||
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
|
||||
parser.add_argument(
|
||||
"--parallel-tests-shell",
|
||||
choices=["default", "cmd", "powershell", "pwsh"],
|
||||
default=os.environ.get("AUTOREVIEW_PARALLEL_TESTS_SHELL", "default"),
|
||||
help="Shell for --parallel-tests. Default preserves Python shell=True platform behavior; use powershell or pwsh for PowerShell-specific commands.",
|
||||
)
|
||||
parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
|
||||
parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
@@ -1129,7 +1217,7 @@ def main() -> int:
|
||||
|
||||
tests_proc: tuple[subprocess.Popen, float] | None = None
|
||||
if args.parallel_tests:
|
||||
tests_proc = start_parallel_tests(args.parallel_tests, repo)
|
||||
tests_proc = start_parallel_tests(args.parallel_tests, repo, args.parallel_tests_shell)
|
||||
try:
|
||||
if len(reviewers) == 1:
|
||||
report = run_reviewer(reviewers[0], repo, prompt, changed_paths, args.require_finding)
|
||||
|
||||
@@ -1,176 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: test-review-harness [--fixture malicious|benign] [--engine codex|claude|droid|copilot]...
|
||||
|
||||
Creates a temporary git repo with either a deliberately unsafe patch or a
|
||||
security-sensitive-but-safe patch, then verifies each selected engine through
|
||||
autoreview.
|
||||
Default engines: codex, claude.
|
||||
EOF
|
||||
}
|
||||
|
||||
engines=()
|
||||
fixture=malicious
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--fixture)
|
||||
fixture=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--engine)
|
||||
engines+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$fixture" in
|
||||
malicious|benign) ;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ${#engines[@]} -eq 0 ]]; then
|
||||
engines=(codex claude)
|
||||
fi
|
||||
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
repo=$(mktemp -d "${TMPDIR:-/tmp}/autoreview-fixture.XXXXXX")
|
||||
trap 'rm -rf "$repo"' EXIT
|
||||
harness="$script_dir/test-review-harness.py"
|
||||
|
||||
cd "$repo"
|
||||
git init --quiet
|
||||
git config user.name "Review Fixture"
|
||||
git config user.email "review-fixture@example.com"
|
||||
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
cat > app.js <<'EOF'
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name.replaceAll("/", "")}`;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > app.js <<'EOF'
|
||||
import path from "node:path";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
EOF
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
exec python3 "$harness" "$@"
|
||||
fi
|
||||
|
||||
git add app.js
|
||||
git commit --quiet -m "initial safe version"
|
||||
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
cat > app.js <<'EOF'
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name}`;
|
||||
}
|
||||
|
||||
export function deleteUpload(name) {
|
||||
return execSync(`rm -rf uploads/${name}`);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name, password: user.password };
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > app.js <<'EOF'
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
const reposRoot = path.resolve("repos");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function repoChildPath(root, name) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error("invalid repo name");
|
||||
return safeChildPath(root, name);
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export async function repoStatus(repoName) {
|
||||
const { stdout } = await execFileAsync("git", ["status", "--short"], {
|
||||
cwd: repoChildPath(reposRoot, repoName),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function accountSettingsForOwner(user, requesterId) {
|
||||
if (String(requesterId) !== String(user.id)) throw new Error("forbidden");
|
||||
return { id: user.id, hasPassword: Boolean(user.passwordHash) };
|
||||
}
|
||||
EOF
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
exec python "$harness" "$@"
|
||||
fi
|
||||
|
||||
for engine in "${engines[@]}"; do
|
||||
echo "== $engine =="
|
||||
if [[ "$fixture" == malicious ]]; then
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
--prompt "This is an acceptance test fixture. The changed app.js patch contains real security bugs. Review normally and report only actionable defects from the patch." \
|
||||
--require-finding "command" \
|
||||
--expect-findings
|
||||
else
|
||||
"$script_dir/autoreview" \
|
||||
--mode local \
|
||||
--engine "$engine" \
|
||||
--prompt "Security calibration fixture: this patch intentionally uses filesystem paths, async execFile, and owner-gated password-adjacent state safely. Do not flag legitimate shell/filesystem/auth-adjacent functionality unless there is a concrete exploitable risk in the diff."
|
||||
fi
|
||||
done
|
||||
echo "Python 3 is required to run test-review-harness." >&2
|
||||
exit 127
|
||||
|
||||
45
.agents/skills/autoreview/scripts/test-review-harness.ps1
Normal file
45
.agents/skills/autoreview/scripts/test-review-harness.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('malicious', 'benign')]
|
||||
[string] $Fixture,
|
||||
|
||||
[ValidateSet('codex', 'claude', 'droid', 'copilot')]
|
||||
[string[]] $Engine,
|
||||
|
||||
[Alias('h')]
|
||||
[switch] $Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Harness = Join-Path $PSScriptRoot 'test-review-harness.py'
|
||||
$ForwardedArgs = @()
|
||||
|
||||
if ($Help) {
|
||||
$ForwardedArgs += '--help'
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Fixture')) {
|
||||
$ForwardedArgs += @('--fixture', $Fixture)
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey('Engine')) {
|
||||
foreach ($SelectedEngine in $Engine) {
|
||||
$ForwardedArgs += @('--engine', $SelectedEngine)
|
||||
}
|
||||
}
|
||||
|
||||
$PyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($null -ne $PyLauncher) {
|
||||
& $PyLauncher.Source -3 $Harness @ForwardedArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$Python = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($null -ne $Python) {
|
||||
& $Python.Source $Harness @ForwardedArgs
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Error 'Python 3 is required to run test-review-harness.'
|
||||
exit 127
|
||||
199
.agents/skills/autoreview/scripts/test-review-harness.py
Normal file
199
.agents/skills/autoreview/scripts/test-review-harness.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ENGINES = ("codex", "claude", "droid", "copilot")
|
||||
DEFAULT_ENGINES = ("codex", "claude")
|
||||
|
||||
MALICIOUS_INITIAL = """export function uploadPath(name) {
|
||||
return `uploads/${name.replaceAll("/", "")}`;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
"""
|
||||
|
||||
BENIGN_INITIAL = r"""import path from "node:path";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
"""
|
||||
|
||||
MALICIOUS_CHANGED = """import { execSync } from "node:child_process";
|
||||
|
||||
export function uploadPath(name) {
|
||||
return `uploads/${name}`;
|
||||
}
|
||||
|
||||
export function deleteUpload(name) {
|
||||
return execSync(`rm -rf uploads/${name}`);
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name, password: user.password };
|
||||
}
|
||||
"""
|
||||
|
||||
BENIGN_CHANGED = r"""import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const uploadsRoot = path.resolve("uploads");
|
||||
const reposRoot = path.resolve("repos");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function safeChildPath(root, name) {
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new Error("invalid child name");
|
||||
}
|
||||
const resolved = path.resolve(root, name);
|
||||
if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function repoChildPath(root, name) {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error("invalid repo name");
|
||||
return safeChildPath(root, name);
|
||||
}
|
||||
|
||||
export function uploadPath(name) {
|
||||
return safeChildPath(uploadsRoot, name);
|
||||
}
|
||||
|
||||
export async function repoProbe(repoName) {
|
||||
const { stdout } = await execFileAsync(process.execPath, ["--version"], {
|
||||
cwd: repoChildPath(reposRoot, repoName),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export function publicUser(user) {
|
||||
return { id: user.id, name: user.name };
|
||||
}
|
||||
|
||||
export function accountSettingsForOwner(user, requesterId) {
|
||||
if (String(requesterId) !== String(user.id)) throw new Error("forbidden");
|
||||
return { id: user.id, hasPassword: Boolean(user.passwordHash) };
|
||||
}
|
||||
"""
|
||||
|
||||
MALICIOUS_PROMPT = "This is an acceptance test fixture. The changed app.js patch contains real security bugs. Review normally and report only actionable defects from the patch."
|
||||
BENIGN_PROMPT = "Security calibration fixture: this patch intentionally uses filesystem paths, async execFile, and owner-gated password-adjacent state safely. Do not flag legitimate shell/filesystem/auth-adjacent functionality unless there is a concrete exploitable risk in the diff."
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="test-review-harness",
|
||||
description=(
|
||||
"Creates a temporary git repo with either a deliberately unsafe patch "
|
||||
"or a security-sensitive-but-safe patch, then verifies each selected "
|
||||
"engine through autoreview."
|
||||
),
|
||||
epilog="Default engines: codex, claude.",
|
||||
)
|
||||
parser.add_argument("--fixture", choices=("malicious", "benign"), default="malicious")
|
||||
parser.add_argument("--engine", action="append", choices=ENGINES, dest="engines")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def write_fixture_file(repo: Path, content: str) -> None:
|
||||
with (repo / "app.js").open("w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
|
||||
|
||||
def run(command: list[str], cwd: Path) -> None:
|
||||
subprocess.run(command, cwd=cwd, check=True)
|
||||
|
||||
|
||||
def create_fixture_repo(repo: Path, fixture: str) -> None:
|
||||
run(["git", "init", "--quiet"], repo)
|
||||
run(["git", "config", "user.name", "Review Fixture"], repo)
|
||||
run(["git", "config", "user.email", "review-fixture@example.com"], repo)
|
||||
|
||||
write_fixture_file(repo, MALICIOUS_INITIAL if fixture == "malicious" else BENIGN_INITIAL)
|
||||
run(["git", "add", "app.js"], repo)
|
||||
run(["git", "commit", "--quiet", "-m", "initial safe version"], repo)
|
||||
write_fixture_file(repo, MALICIOUS_CHANGED if fixture == "malicious" else BENIGN_CHANGED)
|
||||
|
||||
|
||||
def run_reviews(repo: Path, script_dir: Path, fixture: str, engines: list[str]) -> None:
|
||||
autoreview = script_dir / "autoreview"
|
||||
for engine in engines:
|
||||
print(f"== {engine} ==", flush=True)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(autoreview),
|
||||
"--mode",
|
||||
"local",
|
||||
"--engine",
|
||||
engine,
|
||||
"--prompt",
|
||||
MALICIOUS_PROMPT if fixture == "malicious" else BENIGN_PROMPT,
|
||||
]
|
||||
if fixture == "malicious":
|
||||
command.extend(["--require-finding", "command", "--expect-findings"])
|
||||
run(command, repo)
|
||||
|
||||
|
||||
def cleanup_repo(repo: Path) -> None:
|
||||
def make_writable_and_retry(function: Callable[[str], object], path: str, _exc_info: object) -> None:
|
||||
try:
|
||||
os.chmod(path, stat.S_IREAD | stat.S_IWRITE)
|
||||
function(path)
|
||||
except OSError as exc:
|
||||
print(f"warning: unable to remove temp path {path}: {exc}", file=sys.stderr)
|
||||
|
||||
if not repo.exists():
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(repo, onerror=make_writable_and_retry)
|
||||
except OSError as exc:
|
||||
print(f"warning: unable to remove temp repo {repo}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
engines = args.engines or list(DEFAULT_ENGINES)
|
||||
repo = Path(tempfile.mkdtemp(prefix="autoreview-fixture."))
|
||||
try:
|
||||
create_fixture_repo(repo, args.fixture)
|
||||
run_reviews(repo, script_dir, args.fixture, engines)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return int(exc.returncode or 1)
|
||||
finally:
|
||||
cleanup_repo(repo)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -98,7 +98,7 @@ Do not close from title alone. If closing as done on main or nonsensical, prove
|
||||
|
||||
When asked for `5 new`, exclude refs already surfaced in the session and refill from the archive until there are 5 live-open candidates. If fewer than 5 remain open, list all open ones and say how many short.
|
||||
|
||||
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Do not fill the main list with items that merely merged/closed since the last pass; put those numbers in a short bottom line.
|
||||
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Sort by maintainer importance, not recency: high-impact ready fixes first, then useful-but-review-first, then open/not-ready items. Do not include a "changed since last pass" section or bottom-line merged/closed summary unless the user explicitly asks for churn.
|
||||
|
||||
Prefer:
|
||||
|
||||
@@ -142,18 +142,20 @@ No Markdown tables. Compact bullets. Use color/risk markers:
|
||||
Required line shape:
|
||||
|
||||
```markdown
|
||||
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 verifiable: yes. This prevents chat action buttons from overlapping short assistant replies. Blast: web chat rendering, low.
|
||||
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 verifiable: partial. This reports duplicate Telegram replies when reconnecting after gateway restart. Blast: Telegram channel runtime, medium.
|
||||
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 https://github.com/openclaw/openclaw/pull/81244 - Prevents chat action buttons from overlapping short assistant replies. Verifiable: yes. Blast: web chat rendering, low.
|
||||
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 https://github.com/openclaw/openclaw/issues/81245 - Reports duplicate Telegram replies when reconnecting after gateway restart. Verifiable: partial. Blast: Telegram channel runtime, medium.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Bold the `PR #n` or `Issue #n` marker.
|
||||
- Use `@handle`, not author bio text.
|
||||
- Always include the full GitHub URL.
|
||||
- Include a one-line description after the URL, separated with `-`.
|
||||
- PR LOC is `+additions/-deletions`; issue LOC is `LOC n/a`.
|
||||
- Type: `bug`, `feature`, `perf`, `security`, `docs`, `test`, `chore`, or `refactor`.
|
||||
- Write a full sentence for what it does.
|
||||
- Always include blast radius in one phrase.
|
||||
- Always include `verifiable: yes|partial|no` plus the shortest proof hint when helpful.
|
||||
- If status is not open, still show it only when the user asked for all surfaced refs; use ✅ or ⚪ and state merged/closed.
|
||||
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.
|
||||
- For refresh-style asks, prefer section order: `Best Open Now`, `Useful But Review First`, `Still Open / Not Ready`. Omit merged/closed churn by default.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: discrawl
|
||||
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
|
||||
description: "Discord archive: search, sync freshness, DMs, summaries, TUI, repo/release work."
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/openclaw/discrawl
|
||||
@@ -16,29 +16,154 @@ metadata:
|
||||
|
||||
# Discrawl
|
||||
|
||||
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
|
||||
Use local Discord archive data first for Discord questions. Hit Discord APIs
|
||||
only when the archive is stale, missing the requested scope, or the user asks
|
||||
for current external context.
|
||||
|
||||
## Sources
|
||||
|
||||
- DB: platform-native XDG data dir, usually
|
||||
`${XDG_DATA_HOME:-~/.local/share}/discrawl/discrawl.db` on Linux or
|
||||
`~/Library/Application Support/discrawl/discrawl.db` on macOS
|
||||
- Config: platform-native XDG config dir, with legacy fallback to
|
||||
`~/.discrawl/config.toml`
|
||||
- Cache: platform-native XDG cache dir
|
||||
- Logs: platform-native XDG state dir
|
||||
- Git share repo: platform-native XDG data dir
|
||||
- Repo: `openclaw/discrawl`; use `~/GIT/_Perso/discrawl` only after verifying
|
||||
its remote targets `openclaw/discrawl`, otherwise use a fresh checkout
|
||||
- Preferred CLI: `discrawl`; fallback to `go run ./cmd/discrawl` from the repo
|
||||
if the installed binary is stale
|
||||
|
||||
## Freshness
|
||||
|
||||
For recent/current questions, check freshness before analysis:
|
||||
|
||||
```bash
|
||||
discrawl status --json
|
||||
```
|
||||
|
||||
For precise freshness from the default database:
|
||||
|
||||
```bash
|
||||
# Discrawl uses macOS ~/Library defaults unless XDG_DATA_HOME is explicitly set.
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
db="$HOME/Library/Application Support/discrawl/discrawl.db"
|
||||
;;
|
||||
*)
|
||||
db="${XDG_DATA_HOME:-$HOME/.local/share}/discrawl/discrawl.db"
|
||||
;;
|
||||
esac
|
||||
sqlite3 "$db" \
|
||||
"select coalesce(max(updated_at),'') from sync_state where scope like 'channel:%';"
|
||||
```
|
||||
|
||||
Routine diagnostics:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
```
|
||||
|
||||
Refresh only when stale or asked:
|
||||
Desktop-local refresh:
|
||||
|
||||
```bash
|
||||
discrawl sync --source wiretap
|
||||
```
|
||||
|
||||
Bot API latest refresh, when credentials are available:
|
||||
|
||||
```bash
|
||||
discrawl sync
|
||||
```
|
||||
|
||||
Query with bounded slices:
|
||||
Use `--full` only for deliberate historical backfills:
|
||||
|
||||
```bash
|
||||
discrawl sync --full
|
||||
```
|
||||
|
||||
If SQLite reports busy/locked, check for stray `discrawl` processes before retrying.
|
||||
|
||||
## Query Workflow
|
||||
|
||||
1. Resolve scope: guild, channel, DM, author, keyword, date range.
|
||||
2. Check freshness for recent/current requests.
|
||||
3. Prefer CLI search/messages for slices; use read-only SQL for exact counts.
|
||||
4. Report absolute date spans, counts, channel/DM names, and known gaps.
|
||||
|
||||
Use root or subcommand help for syntax: `discrawl --help`,
|
||||
`discrawl help search`, `discrawl search --help`. Use
|
||||
`DISCRAWL_NO_AUTO_UPDATE=1` for read smokes when you do not want git-share
|
||||
updates.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
|
||||
discrawl messages --channel '#maintainers' --days 7 --all
|
||||
discrawl dms --last 20
|
||||
discrawl tui --dm
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
|
||||
```
|
||||
|
||||
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
|
||||
## SQL
|
||||
|
||||
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.
|
||||
Use `discrawl sql` for exact counts, joins, and ranking queries when normal
|
||||
CLI reads are too coarse. The command is read-only by default, accepts SQL as
|
||||
args or stdin, and supports `--json` for agent parsing.
|
||||
|
||||
Useful examples:
|
||||
|
||||
```bash
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) as messages from messages;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(c.name, ''), m.channel_id) as channel, count(*) as messages from messages m left join channels c on c.id = m.channel_id group by m.channel_id order by messages desc limit 20;"
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select coalesce(nullif(mm.display_name, ''), nullif(mm.global_name, ''), nullif(mm.username, ''), m.author_id) as author, count(*) as messages from messages m left join members mm on mm.guild_id = m.guild_id and mm.user_id = m.author_id group by m.guild_id, m.author_id order by messages desc limit 20;"
|
||||
```
|
||||
|
||||
Never use `--unsafe --confirm` unless the user explicitly asks for a database
|
||||
mutation and the write has been reviewed.
|
||||
|
||||
When the installed CLI lacks a new feature, build or run from a verified
|
||||
`openclaw/discrawl` checkout before concluding the feature is missing.
|
||||
|
||||
## Discord Boundaries
|
||||
|
||||
Bot API sync requires configured Discord bot credentials; do not invent token
|
||||
availability. Desktop wiretap mode reads local Discord Desktop artifacts and
|
||||
must not extract credentials, use user tokens, call Discord as the user, or
|
||||
write to Discord application storage. Wiretap/Desktop cache DMs are local-only
|
||||
and must not be described as part of the published Git snapshot. Git-share
|
||||
snapshots must not include secrets or `@me` DM rows.
|
||||
|
||||
## Verification
|
||||
|
||||
For repo edits, prefer existing Go gates:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./...
|
||||
```
|
||||
|
||||
Then run targeted CLI smoke for the touched surface, for example:
|
||||
|
||||
```bash
|
||||
discrawl doctor
|
||||
discrawl status --json
|
||||
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 5 "test"
|
||||
```
|
||||
|
||||
## ClawSweeper Sandbox
|
||||
|
||||
Use the sandbox reader only:
|
||||
|
||||
```bash
|
||||
discrawl-sandbox search --limit 20 "query"
|
||||
discrawl-sandbox messages --channel clawtributors --days 7 --all
|
||||
discrawl-sandbox status --json
|
||||
```
|
||||
|
||||
This reader imports `https://github.com/openclaw/discord-store.git` into
|
||||
`/root/clawsweeper-sandbox-workspace/.discrawl/discrawl.db` with
|
||||
`discord.token_source = "none"`. The published Git snapshot is public-channel
|
||||
filtered; do not use `/root/.discrawl/config.toml` or the rich writer DB from
|
||||
sandboxed public Discord sessions.
|
||||
|
||||
@@ -6,14 +6,16 @@ description: Regenerate OpenClaw release changelog sections from git history bef
|
||||
# OpenClaw Changelog Update
|
||||
|
||||
Use this for release changelog rewrites and GitHub release-note source text.
|
||||
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
|
||||
ordering, and audit discipline.
|
||||
This is mandatory before every beta, beta rerun, stable release, or stable
|
||||
rerun. Use it with `release-openclaw-maintainer`; this skill owns changelog
|
||||
content, ordering, grouping, and attribution discipline.
|
||||
|
||||
## Goal
|
||||
|
||||
Rewrite the target `CHANGELOG.md` version section from history, not from stale
|
||||
draft notes. Produce user-facing release notes sorted by user interest while
|
||||
preserving issue/PR refs and thanks.
|
||||
draft notes. Produce grouped user-facing release notes sorted by user interest
|
||||
while preserving every relevant issue/PR ref and every human `Thanks @...`
|
||||
attribution.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -44,15 +46,35 @@ preserving issue/PR refs and thanks.
|
||||
- `### Highlights`: 5-8 bullets, broad user wins first
|
||||
- `### Changes`: new capabilities and behavior changes
|
||||
- `### Fixes`: user-facing fixes first, grouped by impact and surface
|
||||
- group related changes/fixes by surface and user impact; avoid one bullet
|
||||
per tiny commit when several commits tell one user-facing story
|
||||
6. Preserve attribution:
|
||||
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
|
||||
- every human-authored merged PR represented by a user-facing entry needs
|
||||
its PR ref and `Thanks @author`, even when the PR had no linked issue
|
||||
- every human issue reporter for a `Fixes #...` or referenced bug issue
|
||||
represented by a user-facing entry needs `Thanks @reporter` unless the
|
||||
same handle is already thanked in that bullet
|
||||
- every human `Co-authored-by` contributor on represented user-facing work
|
||||
needs `Thanks @handle` when a GitHub handle is known
|
||||
- when grouping multiple PRs/issues in one bullet, include every relevant
|
||||
PR/issue ref and every human contributor handle in that same bullet
|
||||
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
|
||||
collapse contributor credit just because the note is grouped
|
||||
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
|
||||
and thanks, plus any issue refs from the direct commits
|
||||
- before finalizing, audit the final release-note body:
|
||||
- extract all `#NNN` refs from the notes
|
||||
- resolve which refs are PRs and collect human PR authors
|
||||
- resolve issue refs used as bug/report refs and collect human reporters
|
||||
- scan represented commits for `Co-authored-by`
|
||||
- compare those handles to the final `Thanks @...` set
|
||||
- fix every missing human credit or explicitly record why it is omitted
|
||||
- do not add GHSA references, advisory IDs, or security advisory slugs to
|
||||
changelog entries or GitHub release-note text unless explicitly requested
|
||||
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
|
||||
- if grouping multiple entries, carry all relevant refs and thanks into the
|
||||
grouped bullet
|
||||
- do not use GitHub's release contributor count as the source of truth; the
|
||||
changelog must carry the complete human credit set itself
|
||||
7. Sorting preference:
|
||||
- security/data-loss and content-boundary fixes
|
||||
- transcript/replay/reply delivery correctness
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state.
|
||||
description: "Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state."
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
@@ -85,3 +85,4 @@ jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||
- Public hardening/no-publish comments and draft text should avoid raw commit hashes, PR titles/numbers, and fix-mechanism summaries. Prefer patched-version fields or release-only wording; keep SHAs, PRs, and implementation notes in internal evidence.
|
||||
|
||||
@@ -75,7 +75,9 @@ OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
|
||||
```
|
||||
|
||||
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
|
||||
`pnpm test` wrapper so project routing, workers, and setup stay correct.
|
||||
`pnpm test` wrapper so project routing, workers, and setup stay correct. If raw
|
||||
Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch
|
||||
mode and will not exit on its own.
|
||||
When the checkout is a Codex worktree, prefer the direct node harness instead:
|
||||
|
||||
```bash
|
||||
@@ -213,7 +215,7 @@ workflow only spends setup and queue time on that suite.
|
||||
### Release Evidence
|
||||
|
||||
After release-candidate validation or before a release decision, record the
|
||||
important run ids in the private `openclaw/releases-private` evidence ledger.
|
||||
important run ids in the public `openclaw/releases` evidence ledger.
|
||||
Use the manual `OpenClaw Release Evidence`
|
||||
(`openclaw-release-evidence.yml`) workflow there. It writes durable summaries
|
||||
under `evidence/<release-id>/` and commits:
|
||||
@@ -236,13 +238,13 @@ short release-manager notes there. Do not store raw logs, provider
|
||||
prompts/responses, channel transcripts, signing material, or secret-bearing
|
||||
config in git; raw logs stay in Actions artifacts.
|
||||
|
||||
When `Full Release Validation` completes and
|
||||
`OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN` is configured in the public repo, it
|
||||
requests the private `OpenClaw Release Evidence From Full Validation` workflow.
|
||||
That private workflow reads the parent full-validation run, extracts the child
|
||||
CI/release-checks/Telegram run ids from the parent logs, and opens the evidence
|
||||
PR automatically. If the token is absent or the run predates this wiring, trigger
|
||||
that private workflow manually with the full-validation run id.
|
||||
When `Full Release Validation` completes and `OPENCLAW_RELEASES_DISPATCH_TOKEN`
|
||||
is configured in the source repo, it requests the public
|
||||
`OpenClaw Release Evidence From Full Validation` workflow. That workflow reads
|
||||
the parent full-validation run, extracts the child CI/release-checks/Telegram
|
||||
run ids from the parent logs, and opens the evidence PR automatically. If the
|
||||
token is absent or the run predates this wiring, trigger that workflow manually
|
||||
with the full-validation run id.
|
||||
|
||||
### Release Checks
|
||||
|
||||
|
||||
@@ -21,6 +21,30 @@ function jsonGh(args) {
|
||||
return JSON.parse(gh(args));
|
||||
}
|
||||
|
||||
function githubRestJson(pathSuffix) {
|
||||
const result = execFileSync(
|
||||
"bash",
|
||||
[
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'token="$(gh auth token)"',
|
||||
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
|
||||
].join("\n"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
return JSON.parse(result);
|
||||
}
|
||||
|
||||
function rate() {
|
||||
try {
|
||||
return jsonGh(["api", "rate_limit"]).resources.core;
|
||||
@@ -59,12 +83,30 @@ for (const job of parent.jobs ?? []) {
|
||||
}
|
||||
|
||||
const since = parent.createdAt;
|
||||
const runList = gh([
|
||||
"api",
|
||||
`repos/${repo}/actions/runs?per_page=100`,
|
||||
"--jq",
|
||||
`.workflow_runs[] | select(.created_at >= "${since}") | select(.name=="CI" or .name=="OpenClaw Release Checks" or .name=="Plugin Prerelease" or .name=="NPM Telegram Beta E2E" or .name=="Full Release Validation") | [.id,.name,.status,.conclusion,.head_sha,.html_url] | @tsv`,
|
||||
]).trim();
|
||||
const runsQuery = new URLSearchParams({
|
||||
per_page: "100",
|
||||
created: `>=${since}`,
|
||||
exclude_pull_requests: "true",
|
||||
});
|
||||
const childWorkflowNames = new Set([
|
||||
"CI",
|
||||
"OpenClaw Release Checks",
|
||||
"Plugin Prerelease",
|
||||
"NPM Telegram Beta E2E",
|
||||
"Full Release Validation",
|
||||
]);
|
||||
const runs = githubRestJson(`actions/runs?${runsQuery.toString()}`).workflow_runs ?? [];
|
||||
const runList = runs
|
||||
.filter(
|
||||
(run) =>
|
||||
run.created_at >= since &&
|
||||
run.head_sha === parent.headSha &&
|
||||
childWorkflowNames.has(run.name),
|
||||
)
|
||||
.map((run) =>
|
||||
[run.id, run.name, run.status, run.conclusion ?? "", run.head_sha, run.html_url].join("\t"),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
if (!runList) {
|
||||
console.log("children: none found yet");
|
||||
|
||||
@@ -69,9 +69,13 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
or clawgrit reports. Report regressions explicitly. A major regression is a
|
||||
release blocker unless the operator waives it or the data clearly proves
|
||||
infrastructure noise.
|
||||
- Generate the changelog before version/tag preparation so the top changelog
|
||||
section is deduped and ordered by user impact. Use
|
||||
`$openclaw-changelog-update` for the rewrite.
|
||||
- Generate the changelog before every beta, beta rerun, stable release, or
|
||||
stable rerun, before version/tag preparation. Use
|
||||
`$openclaw-changelog-update` for the rewrite. Do not continue release prep if
|
||||
the target `CHANGELOG.md` section does not have `### Highlights`,
|
||||
`### Changes`, and `### Fixes`, grouped by user-facing surface while
|
||||
preserving every relevant PR/issue ref and every human `Thanks @...`
|
||||
attribution in the grouped bullet.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
stable base version section, for example `v2026.4.20-beta.1` uses
|
||||
`## 2026.4.20` release notes.
|
||||
@@ -144,6 +148,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
section from history, not existing notes. Use the last reachable stable or
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
|
||||
before the new version/tag commit.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
tests, and nearby code when no PR body exists.
|
||||
@@ -157,6 +164,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Add missed user-facing changes, remove internal-only noise, dedupe overlapping
|
||||
PR/direct-commit entries, and sort each section from most to least interesting
|
||||
for users.
|
||||
- Group related highlights, changes, and fixes by user-facing surface and
|
||||
impact, but never lose traceability: each grouped bullet keeps every relevant
|
||||
`#issue`, `(#PR)`, `Fixes #...`, and every human `Thanks @...` handle.
|
||||
Multiple thanks in one bullet are expected when multiple contributor PRs are
|
||||
grouped.
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: security-triage
|
||||
description: Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof.
|
||||
description: "Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof."
|
||||
---
|
||||
|
||||
# Security Triage
|
||||
@@ -87,11 +87,19 @@ When preparing a maintainer-ready close reply:
|
||||
- exact reason for close
|
||||
- exact code refs
|
||||
- exact shipped tag / release facts
|
||||
- exact fix commit or canonical duplicate GHSA when applicable
|
||||
- fix provenance or canonical duplicate GHSA when applicable
|
||||
- optional hardening note only if worthwhile and functionality-preserving
|
||||
|
||||
Keep tone firm, specific, non-defensive.
|
||||
|
||||
## Public Wording Hygiene
|
||||
|
||||
- Keep raw commit hashes, PR titles/numbers, and fix-mechanism summaries out of public advisory text. Use the patched release/version field only.
|
||||
- Keep exact commit SHAs, PRs, and implementation notes in internal notes and verification files.
|
||||
- For hardening/no-publish outcomes, do not add exploit-heavy details, "Fixed by" text, or a "Fix Commit(s)" section. Thank reporters, preserve credit, state the `SECURITY.md` boundary, and say clearly that the GHSA will close without publication.
|
||||
- For published CVE/GHSA text, prefer `### Patched Versions` with the fixed release. Do not explain how the patch works unless Peter explicitly asks for that public detail.
|
||||
- Keep GHSA ids out of changelog and release-note wording unless Peter explicitly asks.
|
||||
|
||||
## Discussion Mode
|
||||
|
||||
When Peter is manually posting GHSA comments, use this flow:
|
||||
|
||||
87
.agents/skills/verify-release/SKILL.md
Normal file
87
.agents/skills/verify-release/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: verify-release
|
||||
description: "Verify an OpenClaw release is fully published across GitHub, npm, plugins, ClawHub, package smoke, and live Gateway agent turns."
|
||||
---
|
||||
|
||||
# Verify Release
|
||||
|
||||
Use this when asked whether an OpenClaw release is fully released, published,
|
||||
promoted, smoke-tested, or live-verified. This is a verification skill, not a
|
||||
publish skill; use `$release-openclaw-maintainer` before changing release state.
|
||||
|
||||
## Rules
|
||||
|
||||
- Resolve short suffixes like `.27` to the concrete CalVer version from the
|
||||
current date/context, then say the resolved version.
|
||||
- Verify live state. Do not trust local checkout state, release notes, or old
|
||||
memory as current truth.
|
||||
- If the checkout is dirty or divergent, use it only for scripts/reference.
|
||||
For version metadata, fetch from GitHub release/tag or unpack the tag tarball
|
||||
under `/tmp`.
|
||||
- Never print secrets. Use inherited live keys only for scoped smoke commands.
|
||||
- Keep the final terse: `yes/no`, evidence bullets, caveats, cleanup.
|
||||
|
||||
## Core Checks
|
||||
|
||||
1. GitHub release:
|
||||
- `gh release view v<VERSION> --repo openclaw/openclaw --json tagName,name,publishedAt,isDraft,isPrerelease,targetCommitish,url,body,assets`
|
||||
- Confirm stable releases are not draft/prerelease.
|
||||
- Confirm release body has npm, CI, plugin npm, ClawHub, mac/appcast evidence
|
||||
links when expected.
|
||||
- Confirm assets expected for stable mac releases are uploaded: zip, dmg,
|
||||
dSYM, dependency evidence when present.
|
||||
2. Root npm:
|
||||
- `npm view openclaw@<VERSION> version dist-tags.latest dist.tarball dist.integrity time.<VERSION> --json`
|
||||
- `latest` must equal `<VERSION>` for stable.
|
||||
- Record tarball, integrity, publish time.
|
||||
3. Plugin publish set:
|
||||
- Get exact tag metadata from GitHub, not the local checkout when dirty:
|
||||
download `https://api.github.com/repos/openclaw/openclaw/tarball/v<VERSION>`
|
||||
into `/tmp/openclaw-v<VERSION>-src`.
|
||||
- Count `extensions/*/package.json` with
|
||||
`openclaw.release.publishToNpm === true` and
|
||||
`openclaw.release.publishToClawHub === true`.
|
||||
- Compare expected counts to workflow job counts:
|
||||
`gh api repos/openclaw/openclaw/actions/runs/<RUN>/jobs --paginate`.
|
||||
- Each expected npm plugin must have version `<VERSION>` and
|
||||
`dist-tags.latest === <VERSION>`.
|
||||
4. ClawHub:
|
||||
- Check the Plugin ClawHub Release workflow conclusion and publish job count.
|
||||
- Use OpenClaw itself for live registry proof:
|
||||
`openclaw plugins search <known-plugin> --json`.
|
||||
- Install one official plugin from ClawHub in an isolated HOME:
|
||||
`openclaw plugins install clawhub:@openclaw/matrix --pin`.
|
||||
Prefer `matrix` unless that plugin is not in the expected set.
|
||||
5. Release workflows:
|
||||
- Verify conclusions for release notes evidence links:
|
||||
Full Release Validation, OpenClaw Release Checks, OpenClaw NPM Release,
|
||||
Plugin NPM Release, Plugin ClawHub Release, mac preflight/validation/publish
|
||||
when stable mac assets are expected.
|
||||
- Summarize only relevant successful/failed jobs; ignore routine skipped
|
||||
optional lanes unless the release body promised them.
|
||||
6. Published package smoke:
|
||||
- In `/tmp`, isolated HOME:
|
||||
`npm exec --yes --package openclaw@<VERSION> -- openclaw --version`.
|
||||
- Run at least one harmless command that touches the published CLI surface,
|
||||
for example `plugins --help` or `gateway --help`.
|
||||
7. Dev Gateway live model smoke:
|
||||
- Use temp HOME/workspace, not the user's normal state:
|
||||
`HOME=/tmp/openclaw-release-smoke/home OPENCLAW_WORKSPACE=/tmp/openclaw-release-smoke/work pnpm openclaw --dev gateway run --auth none --force --verbose`.
|
||||
- Health check via CLI: `openclaw --dev gateway health --json`.
|
||||
- Run one Gateway-backed agent turn with inherited `OPENAI_API_KEY`, short
|
||||
prompt, explicit session key, JSON output, and a known-available model.
|
||||
- If the configured default model fails as unavailable, record that caveat
|
||||
and retry with the newest known-good OpenAI model instead of declaring the
|
||||
release failed.
|
||||
- Stop the gateway and verify the port is not listening.
|
||||
|
||||
## Caveats To Report
|
||||
|
||||
- Dist-tag caveat: stable `latest` is release truth; if optional `beta` mirrors
|
||||
still point at a beta version, report it as a caveat, not a stable-release
|
||||
blocker, unless the user asked to verify beta promotion.
|
||||
- Divergent checkout caveat: say when local source SHA differs from release tag
|
||||
or origin and which live sources were used instead.
|
||||
- Smoke caveat: distinguish Gateway-backed agent success from local embedded
|
||||
fallback. A valid Gateway smoke has health OK plus gateway log/run id for the
|
||||
agent call.
|
||||
@@ -1,21 +1,15 @@
|
||||
profile: openclaw-check
|
||||
provider: aws
|
||||
# Default OpenClaw runner spend to the Azure-backed Crabbox account.
|
||||
# Use `--provider aws` only for AWS-specific runner proof.
|
||||
provider: azure
|
||||
class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
- eu-central-1
|
||||
- us-east-1
|
||||
- us-west-2
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
# Default AWS hydration uses local Actions replay. Use
|
||||
@@ -29,7 +23,14 @@ actions:
|
||||
- openclaw
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
blacksmith:
|
||||
org: openclaw
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
# AWS-specific overrides still pin direct `--provider aws` runs without
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
sync:
|
||||
|
||||
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -11,8 +11,10 @@
|
||||
/.github/workflows/codeql.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
@@ -29,7 +31,7 @@
|
||||
/src/gateway/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/security-path*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/protocol/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/packages/gateway-protocol/src/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/server-methods/secrets*.ts @openclaw/openclaw-secops
|
||||
/src/agents/*auth*.ts @openclaw/openclaw-secops
|
||||
/src/agents/**/*auth*.ts @openclaw/openclaw-secops
|
||||
|
||||
4
.github/actionlint.yaml
vendored
4
.github/actionlint.yaml
vendored
@@ -14,6 +14,10 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-latest
|
||||
- blacksmith-12vcpu-macos-latest
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
- blacksmith-6vcpu-macos-26
|
||||
- blacksmith-12vcpu-macos-26
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
|
||||
24
.github/actions/detect-docs-changes/action.yml
vendored
24
.github/actions/detect-docs-changes/action.yml
vendored
@@ -35,17 +35,29 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
docs_changed=false
|
||||
non_docs=false
|
||||
while IFS= read -r changed_path; do
|
||||
case "$changed_path" in
|
||||
test/fixtures/*)
|
||||
non_docs=true
|
||||
;;
|
||||
docs/* | *.md | *.mdx)
|
||||
docs_changed=true
|
||||
;;
|
||||
*)
|
||||
non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ "$docs_changed" = "true" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
if [ "$non_docs" = "false" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
|
||||
10
.github/actions/ensure-base-commit/action.yml
vendored
10
.github/actions/ensure-base-commit/action.yml
vendored
@@ -38,9 +38,15 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fetch_base_ref() {
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch "$@"
|
||||
}
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
if ! fetch_base_ref --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
@@ -50,7 +56,7 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
|
||||
if ! fetch_base_ref --no-tags origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
|
||||
14
.github/actions/setup-node-env/action.yml
vendored
14
.github/actions/setup-node-env/action.yml
vendored
@@ -20,9 +20,13 @@ inputs:
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore and save the pnpm store with actions/cache.
|
||||
description: Whether to restore the pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
save-actions-cache:
|
||||
description: Whether to save the pnpm store with actions/cache after install when no exact cache restored.
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -45,6 +49,7 @@ runs:
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
id: setup-pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
@@ -130,3 +135,10 @@ runs:
|
||||
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.setup-pnpm.outputs.store-path }}
|
||||
key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}
|
||||
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether actions/cache should cache the pnpm store.
|
||||
description: Whether actions/cache should restore the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
@@ -24,6 +24,15 @@ outputs:
|
||||
project-dir:
|
||||
description: Directory containing the packageManager file used for pnpm resolution.
|
||||
value: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
store-cache-hit:
|
||||
description: Whether the pnpm store cache restored an exact key.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-hit }}
|
||||
store-cache-primary-key:
|
||||
description: Exact pnpm store cache key used for restore/save.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-primary-key }}
|
||||
store-path:
|
||||
description: Resolved pnpm store path.
|
||||
value: ${{ steps.pnpm-store.outputs.path }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -81,14 +90,15 @@ runs:
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
id: pnpm-store-cache
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
|
||||
@@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
|
||||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in "${roots[@]}"; do
|
||||
for node_root in ${roots[@]+"${roots[@]}"}; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then
|
||||
|
||||
@@ -19,7 +19,7 @@ paths:
|
||||
- src/config/types.channel*.ts
|
||||
- src/gateway/server-channel*.ts
|
||||
- src/gateway/server-methods/channels.ts
|
||||
- src/gateway/protocol/schema/channels.ts
|
||||
- packages/gateway-protocol/src/schema/channels.ts
|
||||
- src/infra/channel-*.ts
|
||||
- src/infra/exec-approval-channel-runtime.ts
|
||||
- src/infra/outbound/channel-*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -15,7 +15,7 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/gateway/method-scopes.ts
|
||||
- src/gateway/protocol
|
||||
- packages/gateway-protocol/src
|
||||
- src/gateway/server-methods
|
||||
- src/gateway/server-methods.ts
|
||||
- src/gateway/server-methods-list.ts
|
||||
|
||||
@@ -9,6 +9,7 @@ queries:
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -15,7 +15,6 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/infra/net
|
||||
- src/shared/net
|
||||
- src/agents/tools/web-fetch.ts
|
||||
- src/agents/tools/web-guarded-fetch.ts
|
||||
- src/agents/tools/web-shared.ts
|
||||
@@ -23,6 +22,7 @@ paths:
|
||||
- src/web-fetch
|
||||
- src/web/provider-runtime-shared.ts
|
||||
- packages/memory-host-sdk/src/host/ssrf-policy.ts
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -76,6 +76,8 @@ predicate allowedRawSocketClientCall(Expr call) {
|
||||
or
|
||||
allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/codex-supervisor/src/json-rpc-client.ts", "connectCodexSupervisorUnixSocket")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/qa-lab/src/lab-server-capture.ts", "probeTcpReachability")
|
||||
|
||||
30
.github/labeler.yml
vendored
30
.github/labeler.yml
vendored
@@ -47,6 +47,12 @@
|
||||
- "extensions/meeting-notes/**"
|
||||
- "docs/plugins/meeting-notes.md"
|
||||
- "src/meeting-notes/**"
|
||||
"plugin: workboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/workboard/**"
|
||||
- "docs/plugins/workboard.md"
|
||||
- "docs/plugins/reference/workboard.md"
|
||||
"plugin: migrate-hermes":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -188,7 +194,7 @@
|
||||
- "ui/**"
|
||||
- "src/gateway/control-ui.ts"
|
||||
- "src/gateway/control-ui-shared.ts"
|
||||
- "src/gateway/protocol/**"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/server-methods/chat.ts"
|
||||
- "src/infra/control-ui-assets.ts"
|
||||
|
||||
@@ -196,6 +202,7 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/daemon/**"
|
||||
- "docs/gateway/**"
|
||||
|
||||
@@ -348,6 +355,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/deepinfra/**"
|
||||
- "docs/providers/deepinfra.md"
|
||||
"extensions: gmi":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/gmi/**"
|
||||
- "docs/providers/gmi.md"
|
||||
"extensions: tencent":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -398,6 +410,17 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex/**"
|
||||
"extensions: codex-supervisor":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex-supervisor/**"
|
||||
- "docs/plugins/reference/codex-supervisor.md"
|
||||
- "docs/specs/claw-supervisor.md"
|
||||
"extensions: copilot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot/**"
|
||||
- "docs/plugins/copilot.md"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -418,6 +441,11 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nvidia/**"
|
||||
"extensions: novita":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/novita/**"
|
||||
- "docs/providers/novita.md"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
@@ -188,7 +188,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
@@ -228,7 +231,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
9
.github/workflows/ci-check-testbox.yml
vendored
9
.github/workflows/ci-check-testbox.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Checkout
|
||||
@@ -89,7 +89,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
@@ -130,7 +133,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
374
.github/workflows/ci.yml
vendored
374
.github/workflows/ci.yml
vendored
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -86,12 +86,38 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit 1
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -321,12 +347,38 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit 1
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -414,6 +466,66 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
# Warm the lockfile- and pnpm-pinned store without blocking Linux Node shards.
|
||||
# On a cold key this job owns the save for later workflow runs.
|
||||
pnpm-store-warmup:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
save-actions-cache: "true"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
@@ -422,7 +534,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -485,6 +597,14 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Restore build-all step cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .artifacts/build-all-cache
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
- name: Build dist
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
@@ -582,20 +702,6 @@ jobs:
|
||||
pids+=("$!")
|
||||
}
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
gateway_watch_log="${RUNNER_TEMP}/gateway-watch.log"
|
||||
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000"
|
||||
if node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 >"$gateway_watch_log" 2>&1; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
fi
|
||||
echo "::group::gateway-watch log"
|
||||
cat "$gateway_watch_log"
|
||||
echo "::endgroup::"
|
||||
results["gateway-watch"]="$result"
|
||||
fi
|
||||
|
||||
if [ "$RUN_CHANNELS" = "true" ]; then
|
||||
start_check "channels" env \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 \
|
||||
@@ -610,6 +716,11 @@ jobs:
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
|
||||
fi
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
start_check "gateway-watch" \
|
||||
node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
|
||||
fi
|
||||
|
||||
for index in "${!pids[@]}"; do
|
||||
name="${names[$index]}"
|
||||
log="${logs[$index]}"
|
||||
@@ -723,10 +834,10 @@ jobs:
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
bun-launcher)
|
||||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
||||
@@ -975,7 +1086,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1040,6 +1151,7 @@ jobs:
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1081,7 +1193,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1172,7 +1284,7 @@ jobs:
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1292,7 +1404,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
@@ -1309,10 +1421,22 @@ jobs:
|
||||
find src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
touch -t 200001010000 \
|
||||
if [ -d packages/llm-core/src ]; then
|
||||
find packages/llm-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
if [ -d packages/model-catalog-core/src ]; then
|
||||
find packages/model-catalog-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
cache_inputs=(
|
||||
tsconfig.json \
|
||||
tsconfig.plugin-sdk.dts.json \
|
||||
packages/plugin-sdk/tsconfig.json \
|
||||
packages/llm-core/package.json \
|
||||
packages/model-catalog-core/package.json \
|
||||
scripts/check-extension-package-tsc-boundary.mjs \
|
||||
scripts/prepare-extension-package-boundary-artifacts.mjs \
|
||||
scripts/write-plugin-sdk-entry-dts.ts \
|
||||
@@ -1320,6 +1444,12 @@ jobs:
|
||||
scripts/lib/plugin-sdk-entries.mjs \
|
||||
package.json \
|
||||
pnpm-lock.yaml
|
||||
)
|
||||
for cache_input in "${cache_inputs[@]}"; do
|
||||
if [ -e "$cache_input" ]; then
|
||||
touch -t 200001010000 "$cache_input"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run additional check shard
|
||||
env:
|
||||
@@ -1434,11 +1564,44 @@ jobs:
|
||||
- name: Checkout ClawHub docs source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init clawhub-source
|
||||
git -C clawhub-source config gc.auto 0
|
||||
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
|
||||
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
workdir="$GITHUB_WORKSPACE/clawhub-source"
|
||||
started_at="$(date +%s)"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
|
||||
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
elapsed="$(( $(date +%s) - started_at ))"
|
||||
echo "ClawHub checkout completed in ${elapsed}s"
|
||||
exit 0
|
||||
fi
|
||||
echo "ClawHub checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "ClawHub checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1462,7 +1625,25 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -1510,7 +1691,28 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
@@ -1595,7 +1797,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-15' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1610,7 +1812,28 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -1644,7 +1867,7 @@ jobs:
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1656,7 +1879,28 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
@@ -1867,3 +2111,53 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
name: ci-timings-summary
|
||||
needs:
|
||||
- preflight
|
||||
- security-fast
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts-shard
|
||||
- checks-fast-channel-contracts-shard
|
||||
- checks-node-compat
|
||||
- checks-node-core-test-nondist-shard
|
||||
- check-shard
|
||||
- check-additional-shard
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout timing summary helper
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Write CI timing summary
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
|
||||
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload CI timing summary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-timings-summary
|
||||
path: ci-timings-summary.txt
|
||||
retention-days: 14
|
||||
|
||||
9
.github/workflows/clawsweeper-dispatch.yml
vendored
9
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -24,7 +24,14 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'issue_comment' || !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' ||
|
||||
!(
|
||||
endsWith(github.actor, '[bot]') &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
|
||||
)
|
||||
}}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
|
||||
13
.github/workflows/codeql-critical-quality.yml
vendored
13
.github/workflows/codeql-critical-quality.yml
vendored
@@ -33,6 +33,7 @@ on:
|
||||
- "packages/plugin-package-contract/**"
|
||||
- "packages/plugin-sdk/**"
|
||||
- "packages/memory-host-sdk/**"
|
||||
- "packages/net-policy/**"
|
||||
- "src/*.ts"
|
||||
- "src/**/*.ts"
|
||||
- "src/config/**"
|
||||
@@ -106,13 +107,13 @@ on:
|
||||
- "src/gateway/**/*auth*.ts"
|
||||
- "src/gateway/*secret*.ts"
|
||||
- "src/gateway/**/*secret*.ts"
|
||||
- "src/gateway/protocol/**/*secret*.ts"
|
||||
- "packages/gateway-protocol/src/**/*secret*.ts"
|
||||
- "src/gateway/resolve-configured-secret-input-string*.ts"
|
||||
- "src/gateway/security-path*.ts"
|
||||
- "src/gateway/server-methods/secrets*.ts"
|
||||
- "src/gateway/server-startup-memory.ts"
|
||||
- "src/gateway/method-scopes.ts"
|
||||
- "src/gateway/protocol/**"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/server-methods/**"
|
||||
- "src/gateway/server-methods.ts"
|
||||
- "src/gateway/server-methods-list.ts"
|
||||
@@ -244,14 +245,14 @@ jobs:
|
||||
src/config/*)
|
||||
config=true
|
||||
;;
|
||||
src/gateway/protocol/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
packages/gateway-protocol/src/*secret*.ts|packages/gateway-protocol/src/**/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
core_auth_secrets=true
|
||||
gateway=true
|
||||
;;
|
||||
src/agents/*auth*.ts|src/agents/auth-health*.ts|src/agents/auth-profiles|src/agents/auth-profiles/*|src/agents/bash-tools.exec-host-shared.ts|src/agents/sandbox|src/agents/sandbox.ts|src/agents/sandbox-*.ts|src/agents/sandbox/*|src/cron/service/jobs.ts|src/cron/stagger.ts|src/gateway/*auth*.ts|src/gateway/*secret*.ts|src/gateway/resolve-configured-secret-input-string*.ts|src/gateway/security-path*.ts|src/infra/secret-file*.ts|src/secrets/*|src/security/*)
|
||||
core_auth_secrets=true
|
||||
;;
|
||||
src/gateway/method-scopes.ts|src/gateway/protocol/*|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
gateway=true
|
||||
;;
|
||||
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
@@ -301,7 +302,9 @@ jobs:
|
||||
esac
|
||||
|
||||
case "${file}" in
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts)
|
||||
src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx)
|
||||
;;
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
|
||||
network_runtime=true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -20,7 +20,7 @@ permissions:
|
||||
jobs:
|
||||
macos:
|
||||
name: Critical Security (macOS)
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
20
.github/workflows/codeql.yml
vendored
20
.github/workflows/codeql.yml
vendored
@@ -19,6 +19,15 @@ on:
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -76,10 +85,21 @@ jobs:
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ matrix.category != 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout Actions security sources
|
||||
if: ${{ matrix.category == 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
.github/workflows
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
|
||||
31
.github/workflows/crabbox-hydrate.yml
vendored
31
.github/workflows/crabbox-hydrate.yml
vendored
@@ -137,7 +137,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
- name: Prepare Crabbox shell
|
||||
@@ -318,7 +321,26 @@ jobs:
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (git rev-parse --is-inside-work-tree 2>$null) {
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
$repo = (Get-Location).Path
|
||||
$fetchInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$fetchInfo.FileName = "git"
|
||||
$fetchInfo.WorkingDirectory = $repo
|
||||
$fetchInfo.UseShellExecute = $false
|
||||
$fetchInfo.Arguments = '-c protocol.version=2 fetch --no-tags --no-progress --prune --no-recurse-submodules --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"'
|
||||
|
||||
$fetch = New-Object System.Diagnostics.Process
|
||||
$fetch.StartInfo = $fetchInfo
|
||||
if (-not $fetch.Start()) {
|
||||
throw "git fetch failed to start"
|
||||
}
|
||||
if (-not $fetch.WaitForExit(30000)) {
|
||||
$fetch.Kill()
|
||||
$fetch.WaitForExit()
|
||||
throw "git fetch timed out after 30 seconds"
|
||||
}
|
||||
if ($fetch.ExitCode -ne 0) {
|
||||
throw "git fetch failed with exit code $($fetch.ExitCode)"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Setup pnpm and dependencies
|
||||
@@ -513,7 +535,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
|
||||
176
.github/workflows/dependency-change-awareness.yml
vendored
176
.github/workflows/dependency-change-awareness.yml
vendored
@@ -1,176 +0,0 @@
|
||||
name: Dependency Change Awareness
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-change-awareness-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-change-awareness:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Label and comment on dependency changes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const marker = "<!-- openclaw:dependency-change-awareness -->";
|
||||
const labelName = "dependencies-changed";
|
||||
const maxListedFiles = 25;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
|
||||
if (!pullRequest) {
|
||||
core.info("No pull_request payload found; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDependencyFile = (filename) =>
|
||||
filename === "package.json" ||
|
||||
filename === "package-lock.json" ||
|
||||
filename === "npm-shrinkwrap.json" ||
|
||||
filename === "pnpm-lock.yaml" ||
|
||||
filename === "pnpm-workspace.yaml" ||
|
||||
filename === "ui/package.json" ||
|
||||
filename.startsWith("patches/") ||
|
||||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package-lock\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/npm-shrinkwrap\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
|
||||
|
||||
const sanitizeDisplayValue = (value) =>
|
||||
String(value)
|
||||
.replace(/[\u0000-\u001f\u007f]/gu, "?")
|
||||
.slice(0, 240);
|
||||
const markdownCode = (value) =>
|
||||
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
|
||||
const ignoreUnavailableWritePermission = (action) => (error) => {
|
||||
if (error?.status === 403) {
|
||||
core.warning(
|
||||
`Skipping dependency change ${action}; token does not have issue write permission.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error?.status === 404 || error?.status === 422) {
|
||||
core.warning(`Dependency change ${action} is unavailable.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const dependencyFiles = files
|
||||
.map((file) => file.filename)
|
||||
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingComment = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (dependencyFiles.length === 0) {
|
||||
if (hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: labelName,
|
||||
}).catch(ignoreUnavailableWritePermission("label removal"));
|
||||
}
|
||||
if (existingComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
}).catch(ignoreUnavailableWritePermission("comment deletion"));
|
||||
}
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw("No dependency-related file changes detected.")
|
||||
.write();
|
||||
core.info("No dependency-related file changes detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [labelName],
|
||||
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
|
||||
}
|
||||
|
||||
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
|
||||
const omittedCount = dependencyFiles.length - listedFiles.length;
|
||||
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
|
||||
if (omittedCount > 0) {
|
||||
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
"",
|
||||
"### Dependency Changes Detected",
|
||||
"",
|
||||
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
|
||||
"",
|
||||
"Changed files:",
|
||||
...fileLines,
|
||||
"",
|
||||
"Maintainer follow-up:",
|
||||
"- Review whether the dependency changes are intentional.",
|
||||
"- Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.",
|
||||
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
|
||||
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
|
||||
].join("\n");
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment update"));
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment creation"));
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
|
||||
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
|
||||
.write();
|
||||
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);
|
||||
109
.github/workflows/dependency-guard.yml
vendored
Normal file
109
.github/workflows/dependency-guard.yml
vendored
Normal file
@@ -0,0 +1,109 @@
|
||||
name: Dependency Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-guard-detect:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
autoscrub: ${{ steps.guard.outputs.autoscrub }}
|
||||
autoscrub-owner: ${{ steps.guard.outputs.autoscrub-owner }}
|
||||
autoscrub-repository: ${{ steps.guard.outputs.autoscrub-repository }}
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect dependency changes
|
||||
id: guard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: detect
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
|
||||
dependency-guard-autoscrub:
|
||||
if: ${{ !github.event.pull_request.draft && needs.dependency-guard-detect.outputs.autoscrub == 'true' }}
|
||||
needs: dependency-guard-detect
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create autoscrub app token
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
owner: ${{ needs.dependency-guard-detect.outputs.autoscrub-owner }}
|
||||
repositories: ${{ needs.dependency-guard-detect.outputs.autoscrub-repository }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Create fallback autoscrub app token
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
owner: ${{ needs.dependency-guard-detect.outputs.autoscrub-owner }}
|
||||
repositories: ${{ needs.dependency-guard-detect.outputs.autoscrub-repository }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Remove package lockfile changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_AUTOSCRUB_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: autoscrub
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
|
||||
dependency-guard:
|
||||
if: ${{ !github.event.pull_request.draft && always() }}
|
||||
needs:
|
||||
- dependency-guard-detect
|
||||
- dependency-guard-autoscrub
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enforce dependency guard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_DEPENDENCY_GUARD_MODE: enforce
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
193
.github/workflows/docker-release.yml
vendored
193
.github/workflows/docker-release.yml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -102,14 +103,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
slim_tags+=("${IMAGE}:main-slim-amd64")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-amd64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-amd64")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser-amd64")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}"
|
||||
@@ -119,6 +124,9 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (amd64)
|
||||
@@ -162,6 +170,27 @@ jobs:
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push amd64 browser image
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: |
|
||||
type=gha,scope=docker-release-amd64
|
||||
type=gha,scope=docker-release-browser-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Smoke test amd64 runtime workspace templates
|
||||
shell: bash
|
||||
env:
|
||||
@@ -206,6 +235,26 @@ jobs:
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Smoke test amd64 browser image
|
||||
if: steps.tags.outputs.browser != ''
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t image_refs <<< "${IMAGE_REFS}"
|
||||
image_ref="${image_refs[0]}"
|
||||
if [[ -z "${image_ref}" ]]; then
|
||||
echo "::error::No amd64 browser image ref resolved"
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
|
||||
test -n "${browser}"
|
||||
"${browser}" --version
|
||||
'
|
||||
|
||||
# Build arm64 image. Default and slim tags point to the same slim runtime.
|
||||
build-arm64:
|
||||
needs: [approve_manual_backfill]
|
||||
@@ -217,6 +266,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -244,14 +294,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
slim_tags+=("${IMAGE}:main-slim-arm64")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-arm64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-arm64")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser-arm64")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}"
|
||||
@@ -261,6 +315,9 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (arm64)
|
||||
@@ -304,6 +361,27 @@ jobs:
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push arm64 browser image
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: |
|
||||
type=gha,scope=docker-release-arm64
|
||||
type=gha,scope=docker-release-browser-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Smoke test arm64 runtime workspace templates
|
||||
shell: bash
|
||||
env:
|
||||
@@ -348,6 +426,26 @@ jobs:
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Smoke test arm64 browser image
|
||||
if: steps.tags.outputs.browser != ''
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t image_refs <<< "${IMAGE_REFS}"
|
||||
image_ref="${image_refs[0]}"
|
||||
if [[ -z "${image_ref}" ]]; then
|
||||
echo "::error::No arm64 browser image ref resolved"
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
|
||||
test -n "${browser}"
|
||||
"${browser}" --version
|
||||
'
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
needs: [approve_manual_backfill, build-amd64, build-arm64]
|
||||
@@ -382,18 +480,25 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:main-slim")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
slim_tags+=("${IMAGE}:${version}-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser")
|
||||
fi
|
||||
# Manual backfills should only republish the requested version tags.
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
slim_tags+=("${IMAGE}:slim")
|
||||
tags+=("${IMAGE}:latest" "${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:slim" "${IMAGE}:main-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
@@ -404,25 +509,39 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.value }}
|
||||
BROWSER_TAGS: ${{ steps.tags.outputs.browser }}
|
||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||
AMD64_BROWSER_DIGEST: ${{ needs.build-amd64.outputs.browser_digest }}
|
||||
ARM64_BROWSER_DIGEST: ${{ needs.build-arm64.outputs.browser_digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${TAGS}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
mapfile -t browser_tags <<< "${BROWSER_TAGS}"
|
||||
create_manifest() {
|
||||
local amd64_digest="$1"
|
||||
local arm64_digest="$2"
|
||||
shift 2
|
||||
local args=()
|
||||
for tag in "$@"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest"
|
||||
}
|
||||
create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}"
|
||||
if [[ -n "${BROWSER_TAGS}" ]]; then
|
||||
create_manifest "${AMD64_BROWSER_DIGEST}" "${ARM64_BROWSER_DIGEST}" "${browser_tags[@]}"
|
||||
fi
|
||||
|
||||
verify-attestations:
|
||||
needs: [create-manifest]
|
||||
@@ -460,21 +579,39 @@ jobs:
|
||||
slim_multi_refs=()
|
||||
amd64_refs=()
|
||||
arm64_refs=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
multi_refs+=("${IMAGE}:main")
|
||||
slim_multi_refs+=("${IMAGE}:main-slim")
|
||||
amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64")
|
||||
browser_supported=0
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
tag="${SOURCE_REF#refs/tags/}"
|
||||
git fetch --depth=1 origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
if git show "${SOURCE_REF}:Dockerfile" | grep -q '^ARG OPENCLAW_INSTALL_BROWSER'; then
|
||||
browser_supported=1
|
||||
fi
|
||||
elif grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
multi_refs+=("${IMAGE}:${version}")
|
||||
slim_multi_refs+=("${IMAGE}:${version}-slim")
|
||||
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
|
||||
amd64_refs+=(
|
||||
"${IMAGE}:${version}-amd64"
|
||||
"${IMAGE}:${version}-slim-amd64"
|
||||
)
|
||||
arm64_refs+=(
|
||||
"${IMAGE}:${version}-arm64"
|
||||
"${IMAGE}:${version}-slim-arm64"
|
||||
)
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
multi_refs+=("${IMAGE}:${version}-browser")
|
||||
amd64_refs+=("${IMAGE}:${version}-browser-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-browser-arm64")
|
||||
fi
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
multi_refs+=("${IMAGE}:latest")
|
||||
slim_multi_refs+=("${IMAGE}:slim")
|
||||
multi_refs+=("${IMAGE}:latest" "${IMAGE}:main")
|
||||
slim_multi_refs+=("${IMAGE}:slim" "${IMAGE}:main-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
multi_refs+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then
|
||||
|
||||
26
.github/workflows/full-release-validation.yml
vendored
26
.github/workflows/full-release-validation.yml
vendored
@@ -80,7 +80,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
evidence_package_spec:
|
||||
description: Optional published package spec to prove in the private release evidence report
|
||||
description: Optional published package spec to prove in the release evidence report
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -1450,9 +1450,9 @@ jobs:
|
||||
|
||||
exit "$failed"
|
||||
|
||||
- name: Request private evidence update
|
||||
- name: Request release evidence update
|
||||
env:
|
||||
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
|
||||
RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }}
|
||||
TARGET_REF: ${{ inputs.ref }}
|
||||
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
|
||||
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
|
||||
@@ -1460,11 +1460,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
|
||||
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
|
||||
echo "Release checks were skipped by rerun group; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
|
||||
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1483,7 +1483,7 @@ jobs:
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1509,18 +1509,18 @@ jobs:
|
||||
if ! curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
|
||||
-H "Authorization: Bearer ${RELEASES_DISPATCH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
https://api.github.com/repos/openclaw/releases/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
echo "::warning::Automatic release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
{
|
||||
echo "### Private release evidence dispatch failed"
|
||||
echo "### Release evidence dispatch failed"
|
||||
echo
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases\`:"
|
||||
echo
|
||||
echo "\`\`\`bash"
|
||||
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
|
||||
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
|
||||
echo "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
8
.github/workflows/macos-release.yml
vendored
8
.github/workflows/macos-release.yml
vendored
@@ -93,8 +93,8 @@ jobs:
|
||||
echo "It does not sign, notarize, or upload macOS assets."
|
||||
echo
|
||||
echo "Next step:"
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass."
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight."
|
||||
echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
|
||||
echo "- For stable releases, the private publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
|
||||
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the macOS validation lane to pass."
|
||||
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full macOS preflight."
|
||||
echo "- For the real publish path, run the same macOS publish workflow from \`main\` with the successful preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
|
||||
echo "- For stable releases, the publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -225,7 +225,7 @@ jobs:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -241,9 +241,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
if [[ -n "${PR_NUMBER:-}" ]]; then
|
||||
git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true
|
||||
fi
|
||||
|
||||
resolve_commit() {
|
||||
local input_ref="$2"
|
||||
@@ -257,7 +254,6 @@ jobs:
|
||||
}
|
||||
|
||||
baseline_revision="$(resolve_commit baseline "$BASELINE_REF")"
|
||||
candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")"
|
||||
if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then
|
||||
echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2
|
||||
exit 1
|
||||
@@ -271,6 +267,11 @@ jobs:
|
||||
pr_state="$(jq -r '.state' <<<"$pr_head")"
|
||||
pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")"
|
||||
pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")"
|
||||
candidate_revision="$CANDIDATE_REF"
|
||||
if [[ ! "$candidate_revision" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "candidate ref '${CANDIDATE_REF}' is not an immutable commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then
|
||||
echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2
|
||||
exit 1
|
||||
|
||||
1
.github/workflows/mantis-telegram-live.yml
vendored
1
.github/workflows/mantis-telegram-live.yml
vendored
@@ -377,6 +377,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
||||
|
||||
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -218,6 +218,7 @@ jobs:
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
timeout --preserve-status 300s npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
|
||||
- name: Capture candidate metadata
|
||||
id: candidate_metadata
|
||||
|
||||
@@ -480,6 +480,35 @@ jobs:
|
||||
fi
|
||||
exit 1
|
||||
|
||||
plan_release_workflow_matrices:
|
||||
needs: validate_selected_ref
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
docker_e2e_count: ${{ steps.plan.outputs.docker_e2e_count }}
|
||||
docker_e2e_matrix: ${{ steps.plan.outputs.docker_e2e_matrix }}
|
||||
docker_e2e_omitted_json: ${{ steps.plan.outputs.docker_e2e_omitted_json }}
|
||||
live_models_count: ${{ steps.plan.outputs.live_models_count }}
|
||||
live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }}
|
||||
live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }}
|
||||
steps:
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Plan release workflow matrices
|
||||
id: plan
|
||||
env:
|
||||
DOCKER_LANES: ${{ inputs.docker_lanes }}
|
||||
INCLUDE_LIVE_SUITES: ${{ inputs.include_live_suites }}
|
||||
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
|
||||
LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_providers }}
|
||||
LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }}
|
||||
run: node scripts/plan-release-workflow-matrix.mjs >> "$GITHUB_OUTPUT"
|
||||
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
@@ -636,72 +665,15 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- chunk_id: core
|
||||
label: core
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-core
|
||||
label: package/update core
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: plugins-runtime-plugins
|
||||
label: plugins/runtime plugins
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-services
|
||||
label: plugins/runtime services
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-a
|
||||
label: plugins/runtime install A
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-b
|
||||
label: plugins/runtime install B
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-c
|
||||
label: plugins/runtime install C
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-d
|
||||
label: plugins/runtime install D
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-e
|
||||
label: plugins/runtime install E
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-f
|
||||
label: plugins/runtime install F
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-g
|
||||
label: plugins/runtime install G
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-h
|
||||
label: plugins/runtime install H
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.docker_e2e_matrix) }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1631,42 +1603,14 @@ jobs:
|
||||
|
||||
validate_live_models_docker:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0'
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- provider_label: Anthropic
|
||||
providers: anthropic
|
||||
profiles: stable full
|
||||
- provider_label: Google
|
||||
providers: google
|
||||
profiles: stable full
|
||||
- provider_label: MiniMax
|
||||
providers: minimax
|
||||
profiles: stable full
|
||||
- provider_label: OpenAI
|
||||
providers: openai
|
||||
profiles: beta minimum stable full
|
||||
- provider_label: OpenCode
|
||||
providers: opencode-go
|
||||
profiles: full
|
||||
- provider_label: OpenRouter
|
||||
providers: openrouter
|
||||
profiles: full
|
||||
- provider_label: xAI
|
||||
providers: xai
|
||||
profiles: full
|
||||
- provider_label: Z.ai
|
||||
providers: zai
|
||||
profiles: full
|
||||
- provider_label: Fireworks
|
||||
providers: fireworks
|
||||
profiles: full
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.live_models_matrix) }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1744,6 +1688,8 @@ jobs:
|
||||
- name: Validate provider credential
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
shell: bash
|
||||
env:
|
||||
LIVE_MODEL_PROVIDERS: ${{ matrix.providers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1760,7 +1706,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${{ matrix.providers }}" in
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
@@ -1771,7 +1717,7 @@ jobs:
|
||||
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
|
||||
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
|
||||
*)
|
||||
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
|
||||
echo "Unhandled live model provider shard: ${LIVE_MODEL_PROVIDERS}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1986,7 +1932,7 @@ jobs:
|
||||
- suite_id: native-live-src-gateway-profiles-anthropic-opus
|
||||
suite_group: native-live-src-gateway-profiles-anthropic
|
||||
label: Native live gateway profiles Anthropic Opus
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-8 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -2001,19 +1947,19 @@ jobs:
|
||||
profiles: full
|
||||
- suite_id: native-live-src-gateway-profiles-google
|
||||
label: Native live gateway profiles Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-openai
|
||||
label: Native live gateway profiles OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2288,7 +2234,7 @@ jobs:
|
||||
include:
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2300,13 +2246,13 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-google-docker
|
||||
label: Docker live gateway Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
16
.github/workflows/openclaw-npm-release.yml
vendored
16
.github/workflows/openclaw-npm-release.yml
vendored
@@ -47,8 +47,8 @@ jobs:
|
||||
# KEEP THIS WORKFLOW SHORT AND DETERMINISTIC OR IT CAN GET STUCK AND JEOPARDIZE THE RELEASE.
|
||||
# RELEASE-TIME LIVE OR END-TO-END VALIDATION BELONGS IN openclaw-release-checks.yml.
|
||||
# SECURITY NOTE: TOKEN-BASED npm dist-tag mutation moved to
|
||||
# openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this public workflow can stay focused on OIDC publish only.
|
||||
# openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this source workflow can stay focused on OIDC publish only.
|
||||
preflight_openclaw_npm:
|
||||
if: ${{ inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -372,6 +372,11 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Require trusted workflow ref for publish
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
@@ -429,12 +434,13 @@ jobs:
|
||||
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
direct_recovery=true
|
||||
echo "Direct OpenClaw npm recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
publish_openclaw_npm:
|
||||
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
|
||||
|
||||
@@ -813,7 +813,7 @@ jobs:
|
||||
alt_model="openai/gpt-5.5-alt"
|
||||
;;
|
||||
baseline)
|
||||
model="anthropic/claude-opus-4-7"
|
||||
model="anthropic/claude-opus-4-8"
|
||||
alt_model="anthropic/claude-sonnet-4-6"
|
||||
;;
|
||||
*)
|
||||
@@ -885,7 +885,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -1207,6 +1207,7 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
run: |
|
||||
|
||||
@@ -122,6 +122,10 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
|
||||
echo "publish_openclaw_npm=true requires plugin_publish_scope=all-publishable so every publishable official plugin is released with OpenClaw." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
|
||||
echo "plugin_publish_scope=selected requires plugins." >&2
|
||||
exit 1
|
||||
@@ -810,7 +814,7 @@ jobs:
|
||||
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
|
||||
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
|
||||
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
|
||||
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
|
||||
6
.github/workflows/opengrep-precise-full.yml
vendored
6
.github/workflows/opengrep-precise-full.yml
vendored
@@ -32,11 +32,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
8
.github/workflows/opengrep-precise.yml
vendored
8
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
20
.github/workflows/plugin-clawhub-release.yml
vendored
20
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -207,6 +207,11 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -222,12 +227,13 @@ jobs:
|
||||
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin ClawHub Release recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-release environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
@@ -431,7 +437,8 @@ jobs:
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -456,14 +463,17 @@ jobs:
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
exit 1
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
|
||||
20
.github/workflows/plugin-npm-release.yml
vendored
20
.github/workflows/plugin-npm-release.yml
vendored
@@ -184,6 +184,11 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -199,12 +204,13 @@ jobs:
|
||||
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
|
||||
exit 1
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin NPM Release recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_npm
|
||||
@@ -263,7 +269,8 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Ensure version is not already published
|
||||
- name: Check npm package version
|
||||
id: npm_package_version
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -271,10 +278,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.npm_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -199,13 +199,13 @@ jobs:
|
||||
--alt-model openai/gpt-5.5-alt \
|
||||
--output-dir .artifacts/qa-e2e/openai-candidate
|
||||
|
||||
- name: Run Opus 4.7 lane
|
||||
- name: Run Opus 4.8 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-7 \
|
||||
--model anthropic/claude-opus-4-8 \
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/anthropic-baseline
|
||||
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -530,6 +530,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
|
||||
2
.github/workflows/website-installer-sync.yml
vendored
2
.github/workflows/website-installer-sync.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
|
||||
|
||||
macos-installer:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -197,4 +197,4 @@ jobs:
|
||||
|
||||
- name: Testbox action marker
|
||||
if: ${{ false }}
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
|
||||
|
||||
15
.github/workflows/workflow-sanity.yml
vendored
15
.github/workflows/workflow-sanity.yml
vendored
@@ -34,7 +34,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -75,7 +78,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install actionlint
|
||||
@@ -116,7 +122,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -178,6 +178,7 @@ mantis/
|
||||
/local/
|
||||
/client_secret_*.json
|
||||
package-lock.json
|
||||
!src/commands/copilot-sdk-install-manifest/package-lock.json
|
||||
.claude/
|
||||
.agent/
|
||||
skills-lock.json
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
"skills/",
|
||||
"skills/**",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"vendor/",
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -35,9 +35,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Map
|
||||
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `packages/gateway-protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Installers: sibling `../openclaw.ai`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,agents}/`, `packages/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -57,6 +57,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
@@ -71,12 +72,13 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Plugin SDK exception: shipped external API gets new API first plus named compat/deprecation, small tests/docs if useful, removal plan.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Agent run terminal state: normalize/merge via `src/agents/agent-run-terminal-outcome.ts`; do not rederive timeout/cancel precedence in projections.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
|
||||
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
|
||||
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
|
||||
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
|
||||
- Inline comments: preserve reviewer context at the code site. Required for non-obvious cross-path/state invariants, lifecycle ordering, ownership boundaries, queue/dedupe symmetry, TTL/cache expiry, cleanup/release coupling, session/id adoption, fallback behavior, platform/dependency caps, deterministic ordering, compact encoded state, or intentional caller differences.
|
||||
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
@@ -91,6 +93,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- If raw Vitest is unavoidable, use `vitest run ...`; bare `vitest ...` starts local watch mode and will not exit on its own.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
|
||||
@@ -226,6 +229,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- Memory wiki prompt digest stays tiny; prefer `wiki_search` / `wiki_get`; verify contact data before use; source-class provenance for generated people facts.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
|
||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -2,14 +2,77 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.5.30
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
|
||||
### Changes
|
||||
|
||||
- Skills: let the `skill_research` agent tool apply, reject, and quarantine explicit Skill Workshop proposals through the guarded proposal lifecycle. Thanks @shakkernerd.
|
||||
- Skills: let Skill Workshop proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending Skill Workshop proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
- Skills: add Skill Workshop proposals with pending `PROPOSAL.md` drafts, CLI/Gateway review actions, rollback metadata, and the `skill_research` agent tool. Thanks @shakkernerd.
|
||||
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
|
||||
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
|
||||
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
|
||||
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
|
||||
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
|
||||
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
|
||||
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
|
||||
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
|
||||
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.
|
||||
- Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.
|
||||
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.
|
||||
|
||||
## 2026.5.28
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
|
||||
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
|
||||
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
|
||||
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
|
||||
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)
|
||||
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)
|
||||
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman and @BunsDev.
|
||||
- Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)
|
||||
- Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)
|
||||
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.
|
||||
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)
|
||||
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
|
||||
|
||||
### Changes
|
||||
@@ -17,18 +80,41 @@ Docs: https://docs.openclaw.ai
|
||||
- Status: show active subagent details in status output.
|
||||
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
|
||||
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
- iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
- PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)
|
||||
- Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.
|
||||
- Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.
|
||||
- Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.
|
||||
- Workboard: add agent coordination tools for tracking and handing off active agent work.
|
||||
- Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)
|
||||
- Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @piersonr and @RomneyDa.
|
||||
- Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
|
||||
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
|
||||
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, @benjamin1492, @c19354837, @fuller-stack-dev, @pfrederiksen, and @dodge1218.
|
||||
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @jarvis-mns1, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @lidge-jun, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.
|
||||
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.
|
||||
- Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.
|
||||
- Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887) Thanks @chen-zhang-cs-code.
|
||||
- Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)
|
||||
- WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492) Thanks @lidge-jun.
|
||||
- Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.
|
||||
- Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)
|
||||
- Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611) Thanks @ferminquant.
|
||||
- Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @Pluviobyte and @eleqtrizit.
|
||||
- Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)
|
||||
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.
|
||||
- Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)
|
||||
|
||||
## 2026.5.27
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
|
||||
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
|
||||
# Copy pinned Bun binary from the official image instead of fetching via curl.
|
||||
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
|
||||
@@ -77,7 +78,12 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
# still exiting successfully, so retry the package downloader before failing.
|
||||
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
|
||||
RUN set -eux; \
|
||||
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
|
||||
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "==> Verifying critical native addons..."; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \
|
||||
|
||||
489
appcast.xml
489
appcast.xml
@@ -2,6 +2,117 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.5.28</title>
|
||||
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052890</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
|
||||
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
|
||||
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
|
||||
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
|
||||
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
|
||||
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
|
||||
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
|
||||
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Status: show active subagent details in status output.</li>
|
||||
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
|
||||
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
|
||||
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
|
||||
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
|
||||
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
|
||||
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
|
||||
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
|
||||
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
|
||||
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
|
||||
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
|
||||
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
|
||||
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
|
||||
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
|
||||
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
|
||||
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
|
||||
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
|
||||
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
|
||||
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
|
||||
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
|
||||
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
|
||||
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
|
||||
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
|
||||
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
|
||||
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
|
||||
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
|
||||
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
|
||||
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
|
||||
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
|
||||
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
|
||||
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
|
||||
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
|
||||
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
|
||||
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
|
||||
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
|
||||
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
|
||||
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
|
||||
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
|
||||
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
|
||||
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
|
||||
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
|
||||
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
|
||||
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
|
||||
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
|
||||
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
|
||||
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
|
||||
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
|
||||
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
|
||||
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
|
||||
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.26</title>
|
||||
<pubDate>Wed, 27 May 2026 12:24:26 +0000</pubDate>
|
||||
@@ -211,383 +322,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.22</title>
|
||||
<pubDate>Sun, 24 May 2026 01:41:27 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.22</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.22</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.</li>
|
||||
<li>Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.</li>
|
||||
<li>Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.</li>
|
||||
<li>Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.</li>
|
||||
<li>Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only <code>openclaw meeting-notes</code> CLI access, and Discord voice as the first live source.</li>
|
||||
<li>Docs/channels/config: add Signal <code>configPath</code>, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.</li>
|
||||
<li>Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.</li>
|
||||
<li>Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.</li>
|
||||
<li>Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.</li>
|
||||
<li>Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.</li>
|
||||
<li>Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.</li>
|
||||
<li>Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.</li>
|
||||
<li>Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.</li>
|
||||
<li>Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.</li>
|
||||
<li>Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.</li>
|
||||
<li>Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.</li>
|
||||
<li>Agents/subagents: limit default sub-agent bootstrap context to <code>AGENTS.md</code> and <code>TOOLS.md</code>, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.</li>
|
||||
<li>Maintainer skills: exclude plugin SDK/API boundary work from <code>openclaw-landable-bug-sweep</code> so bugbash sweeps stay focused on small paper-cut fixes.</li>
|
||||
<li>QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.</li>
|
||||
<li>Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.</li>
|
||||
<li>Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as <code>docker</code> and <code>blacksmith</code>. (#85302) Thanks @hxy91819.</li>
|
||||
<li>Maintainer skills: add <code>openclaw-landable-bug-sweep</code> for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.</li>
|
||||
<li>Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.</li>
|
||||
<li>CLI/onboarding: start classic onboarding when bare <code>openclaw</code> runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord: allow configuring a bounded <code>agentComponents.ttlMs</code> callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.</li>
|
||||
<li>xAI/Grok: reuse xAI OAuth auth profiles for Grok <code>web_search</code>, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.</li>
|
||||
<li>Plugin SDK: add row-level session workflow helpers and deprecate <code>loadSessionStore</code> so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.</li>
|
||||
<li>Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.</li>
|
||||
<li>Plugins/SDK: add a general <code>embeddingProviders</code> capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.</li>
|
||||
<li>Dependencies: refresh provider, plugin, UI, and tooling packages, update <code>protobufjs</code> to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to <code>@agentclientprotocol/claude-agent-acp</code> 0.36.1.</li>
|
||||
<li>Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.</li>
|
||||
<li>QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.</li>
|
||||
<li>QA-Lab: replace generic evidence framing in seeded scenario prompts with concrete observed QA behavior.</li>
|
||||
<li>QA-Lab: list named scenario packs in the coverage report so personal-agent privacy coverage stays visible in audits.</li>
|
||||
<li>QA-Lab: list live transport lane membership in the coverage report so real transport checks stay separate from seeded qa-channel scenarios.</li>
|
||||
<li>Release/package: run package integrity checks before package acceptance lanes so public install/update validation fails before private QA assets can leak into the package.</li>
|
||||
<li>QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: include an opt-in <code>update.run</code> package self-upgrade sentinel for destructive latest-package recovery checks.</li>
|
||||
<li>QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.</li>
|
||||
<li>Models/perf: pre-warm the provider auth-state map at gateway startup so <code>/models</code> and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.</li>
|
||||
<li>Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap, support bundled plugin runtime dependencies for suitable plugin tarballs, and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.</li>
|
||||
<li>Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so <code>doctor-core-checks</code> no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.</li>
|
||||
<li>Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.</li>
|
||||
<li>Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.</li>
|
||||
<li>Telegram/ACP: preserve explicit <code>:topic:</code> conversation suffixes when inbound ACP targets do not carry a separate thread id.</li>
|
||||
<li>Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so <code>openclaw browser start</code> works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.</li>
|
||||
<li>Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.</li>
|
||||
<li>OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.</li>
|
||||
<li>Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.</li>
|
||||
<li>Checks/Windows: route full <code>pnpm check</code> stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.</li>
|
||||
<li>Checks/Windows: run managed child commands through explicit <code>cmd.exe</code> wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.</li>
|
||||
<li>Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.</li>
|
||||
<li>Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.</li>
|
||||
<li>Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.</li>
|
||||
<li>Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.</li>
|
||||
<li>Channels: honor <code>/verbose on</code> for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.</li>
|
||||
<li>CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.</li>
|
||||
<li>Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.</li>
|
||||
<li>Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.</li>
|
||||
<li>Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.</li>
|
||||
<li>Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.</li>
|
||||
<li>Telegram: send local <code>path</code>/<code>filePath</code> and structured attachment media from <code>sendMessage</code> actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.</li>
|
||||
<li>Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.</li>
|
||||
<li>Gateway/config: pin relative <code>OPENCLAW_STATE_DIR</code> overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.</li>
|
||||
<li>Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute <code>npm.cmd</code> instead of treating it as a binary.</li>
|
||||
<li>Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.</li>
|
||||
<li>Secrets: show the irreversible apply warning after interactive <code>secrets configure</code> confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.</li>
|
||||
<li>Agents/CLI output: ignore cumulative Claude <code>stream-json</code> result usage when assistant usage events are present, preventing inflated cache-read accounting. (#85625) Thanks @zhouhe-xydt.</li>
|
||||
<li>CLI: keep <code>waitForever()</code> alive by leaving its keep-alive interval ref'd so the public helper no longer exits immediately with Node's unsettled-await code. (#85694) Thanks @m1qaweb.</li>
|
||||
<li>Agents/bootstrap: guard bootstrap name checks against missing file names so malformed bootstrap entries warn and truncate instead of crashing. Fixes #85523. (#85615) Thanks @zhouhe-xydt.</li>
|
||||
<li>CLI/tasks: reject partially numeric <code>openclaw tasks audit --limit</code> values so audit limits must be real positive integers instead of accepting strings like <code>5abc</code>. (#84901) Thanks @jbetala7.</li>
|
||||
<li>Status/diagnostics: bound deep Docker audit probes so <code>openclaw status --deep</code> reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.</li>
|
||||
<li>Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired <code>context-1m-2025-08-07</code> beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.</li>
|
||||
<li>Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including <code>:topic:</code> and <code>:topicId</code> forms for announce delivery. Thanks @etticat.</li>
|
||||
<li>Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.</li>
|
||||
<li>Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.</li>
|
||||
<li>Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.</li>
|
||||
<li>Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using <code>message</code>, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.</li>
|
||||
<li>Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.</li>
|
||||
<li>Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.</li>
|
||||
<li>Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)</li>
|
||||
<li>StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.</li>
|
||||
<li>Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.</li>
|
||||
<li>Windows installer: fail Git checkout installs when <code>pnpm install</code> or <code>pnpm build</code> fails instead of writing a wrapper to a missing CLI build.</li>
|
||||
<li>Sessions: surface previous-transcript archive failures during <code>/new</code> rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.</li>
|
||||
<li>TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in <code>openclaw tui</code>. Fixes #85538. Thanks @danpolasek.</li>
|
||||
<li>Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.</li>
|
||||
<li>Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.</li>
|
||||
<li>Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.</li>
|
||||
<li>Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.</li>
|
||||
<li>Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.</li>
|
||||
<li>QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.</li>
|
||||
<li>Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.</li>
|
||||
<li>Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.</li>
|
||||
<li>Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.</li>
|
||||
<li>Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)</li>
|
||||
<li>Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.</li>
|
||||
<li>OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.</li>
|
||||
<li>OpenAI video: send uploaded video edit requests to the documented <code>/videos/edits</code> endpoint with a <code>video</code> file instead of posting MP4 references to <code>/videos</code>. Thanks @shakkernerd.</li>
|
||||
<li>Agents/channels: preserve message-tool delivery evidence through gateway agent completion handoffs so successful generated media sends are not followed by false failure messages. Thanks @shakkernerd.</li>
|
||||
<li>CLI/update: repair managed npm plugin <code>openclaw</code> peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.</li>
|
||||
<li>CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.</li>
|
||||
<li>Codex app-server: let authorized <code>/codex</code> control commands such as <code>/codex detach</code> escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.</li>
|
||||
<li>Auto-reply/models: keep <code>/models</code> browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.</li>
|
||||
<li>Codex app-server: disable native Code Mode when the effective exec host is <code>node</code> and keep OpenClaw <code>exec</code>/<code>process</code> available, so <code>/exec host=node</code> routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.</li>
|
||||
<li>Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.</li>
|
||||
<li>Gateway/agents: return phase-aware <code>agent.wait</code> timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.</li>
|
||||
<li>Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.</li>
|
||||
<li>Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.</li>
|
||||
<li>Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.</li>
|
||||
<li>Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.</li>
|
||||
<li>CLI/update: walk the macOS process ancestry and honor the inherited Gateway runtime PID before package updates stop the managed Gateway service, so nested in-band updater children can refuse instead of killing the LaunchAgent-supervised Gateway that owns them. Fixes #85120.</li>
|
||||
<li>Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.</li>
|
||||
<li>Gateway/LaunchAgent: treat a concurrent launchd bootstrap as a successful restart when the service is already loaded, avoiding false macOS Gateway restart failures. Fixes #84721. (#84722) Thanks @googlerest.</li>
|
||||
<li>Gateway/service: include the active <code>openclaw</code> command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.</li>
|
||||
<li>Control UI/chat: disable the thinking selector for known non-reasoning models instead of showing duplicate Off choices. Fixes #84069. Thanks @DrippingMellow.</li>
|
||||
<li>Memory: expand <code>~</code> in configured extra memory paths before resolving them, so home-relative folders are not treated as workspace-relative. Fixes #58026. Thanks @stadman.</li>
|
||||
<li>Skills: treat <code>openclaw.os: macos</code> as Darwin when checking skill requirements, so macOS-only skills no longer report as missing on macOS hosts. Fixes #61338. Thanks @Jessecq1995.</li>
|
||||
<li>Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.</li>
|
||||
<li>Docker: pre-create the workspace and auth-profile config mount points with <code>node</code> ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.</li>
|
||||
<li>Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.</li>
|
||||
<li>CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)</li>
|
||||
<li>Agents/providers: honor per-model <code>api</code> and <code>baseUrl</code> overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.</li>
|
||||
<li>Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.</li>
|
||||
<li>CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.</li>
|
||||
<li>Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (<code>EMFILE</code>) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.</li>
|
||||
<li>Release/security: keep generated npm shrinkwrap package versions inside the pnpm lock graph so published package locks cannot bypass pnpm dependency age and override policy.</li>
|
||||
<li>Cron: honor <code>cron.retry.retryOn: ["network"]</code> for common network error codes such as <code>EAI_AGAIN</code>, <code>EHOSTUNREACH</code>, and <code>ENETUNREACH</code>.</li>
|
||||
<li>Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.</li>
|
||||
<li>Gateway chat display: preserve OpenAI-compatible <code>prompt_tokens</code>, <code>completion_tokens</code>, and <code>total_tokens</code> usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.</li>
|
||||
<li>Dashboard/CLI: allow macOS browser launching through <code>open</code> even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.</li>
|
||||
<li>Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.</li>
|
||||
<li>OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated <code>kimi-k2.6</code> turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.</li>
|
||||
<li>Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.</li>
|
||||
<li>Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.</li>
|
||||
<li>Doctor/Codex: point native Codex asset warnings at the canonical <code>openclaw migrate plan codex</code> preview command. Fixes #84948. Thanks @markoa.</li>
|
||||
<li>CLI/models: make <code>capability model auth logout --agent</code> remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.</li>
|
||||
<li>Gateway/models: reuse prepared provider auth metadata during model-listing auth checks so repeated lookups avoid broad plugin discovery while preserving synthetic local auth.</li>
|
||||
<li>CLI/status: suppress systemd user-service setup hints when <code>openclaw status --deep</code> can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.</li>
|
||||
<li>CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.</li>
|
||||
<li>CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded <code>openclaw agent</code> execution.</li>
|
||||
<li>CLI/update: keep managed Gateway service stop/restart status lines out of <code>openclaw update --json</code> stdout so package-update automation can parse the JSON payload.</li>
|
||||
<li>Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.</li>
|
||||
<li>Agents/OpenAI: preserve Responses and Chat Completions <code>reasoning_tokens</code> usage metadata without double-counting it in aggregate output tokens. (#85319)</li>
|
||||
<li>Control UI/chat: convert pasted <code>data:image/...;base64,...</code> clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.</li>
|
||||
<li>Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.</li>
|
||||
<li>OpenAI Codex: preserve image input support for sparse <code>openai-codex/gpt-5.5</code> catalog rows. (#85095) Thanks @sercada.</li>
|
||||
<li>CLI/models: add a piped or pasted API-key path for OpenAI Codex auth and warn when API keys are pasted into token-mode auth. (#85533) Thanks @joshavant.</li>
|
||||
<li>Telegram: dead-letter missing-harness isolated ingress failures so a poisoned spooled update no longer blocks later same-lane messages. Fixes #85470. (#85605) Thanks @joshavant.</li>
|
||||
<li>Plugins/discovery: strip <code>-plugin</code> package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.</li>
|
||||
<li>Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.</li>
|
||||
<li>Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives <code>language-*</code> code classes. (#85209) Thanks @leno23.</li>
|
||||
<li>Windows installer: run npm and Corepack command shims from a Windows-local directory so installs launched from WSL2 UNC paths do not fail before OpenClaw is installed.</li>
|
||||
<li>Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.</li>
|
||||
<li>Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.</li>
|
||||
<li>Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts.</li>
|
||||
<li>Windows installer: extract the downloaded portable Node.js directory with native <code>tar</code> before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.</li>
|
||||
<li>fix(integrations): enforce channel read target allowlists [AI]. (#84982) Thanks @pgondhi987.</li>
|
||||
<li>Agents/heartbeat: route single-owner <code>session.dmScope=main</code> direct-message exec and cron event wakes back to the agent main session so async completions no longer strand context in orphan direct-DM queues. Fixes #71581. (#83743) Thanks @Kaspre.</li>
|
||||
<li>Agents/code-mode: expose outer code-mode <code>exec</code> source through the <code>command</code> hook alias with <code>toolKind</code>/<code>toolInputKind</code> discriminators so exec-shaped policies can distinguish code-mode cells. (#83483) Thanks @Kaspre.</li>
|
||||
<li>Agents/code mode: return structured timeout and runtime-unavailable error codes for known worker failures. Fixes #83389. (#83444) Thanks @Kaspre.</li>
|
||||
<li>QA-Lab: isolate multi-scenario suite workers when scenarios need startup config patches, preventing message-routing config from leaking into unrelated scenarios.</li>
|
||||
<li>QA-Lab: make the commitments heartbeat-target-none scenario request an immediate heartbeat instead of waiting for the next scheduled heartbeat.</li>
|
||||
<li>Codex/Plugin SDK: deliver Codex-native subagent completions through a generic harness task runtime so harness-backed plugins can mirror durable task lifecycle and completion delivery without Codex-specific SDK imports. (#83445) Thanks @bryanpearson.</li>
|
||||
<li>Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.</li>
|
||||
<li>Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.</li>
|
||||
<li>Agents/exec: treat denied exec approvals as terminal instead of feeding them back into agent follow-up work, and recognize Chinese stop phrases in abort handling. Fixes #69386. (#85194) Thanks @samzong.</li>
|
||||
<li>CLI/agents: abort accepted Gateway-backed <code>openclaw agent</code> runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.</li>
|
||||
<li>Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle <code>turn/completed</code> timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.</li>
|
||||
<li>Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to <code>appServer.args</code>, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.</li>
|
||||
<li>UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.</li>
|
||||
<li>fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.</li>
|
||||
<li>Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.</li>
|
||||
<li>Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.</li>
|
||||
<li>Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.</li>
|
||||
<li>Agents/hooks: wait for local one-shot CLI and Codex <code>agent_end</code> plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)</li>
|
||||
<li>Providers/Google: preserve Gemini 3 cron <code>thinkingDefault: "low"</code> when stale catalog metadata says <code>reasoning:false</code>, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.</li>
|
||||
<li>CLI/agents: allow <code>openclaw agent --session-key</code> to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.</li>
|
||||
<li>Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.</li>
|
||||
<li>Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.</li>
|
||||
<li>Channels: treat bare abort messages such as <code>stop</code>, <code>abort</code>, and <code>wait</code> as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.</li>
|
||||
<li>Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so <code>openclaw agent --local</code> message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.</li>
|
||||
<li>Agents/heartbeat: honor group/channel <code>message_tool</code> visible-reply policy and model-specific Codex runtime config for scheduled heartbeat runs, so failed internal tool output stays private. Fixes #85310. (#85357) Thanks @neeravmakwana.</li>
|
||||
<li>Gateway/ACP: close child ACP sessions spawned via <code>sessions_spawn</code> when their parent session is reset or deleted, instead of leaving orphaned <code>claude-agent-acp</code> processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf.</li>
|
||||
<li>Codex app-server: block native execution paths when OpenClaw exec resolves to a node host while preserving the first-party CLI node binding path. Fixes #85012. (#85534) Thanks @joshavant.</li>
|
||||
<li>Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry.</li>
|
||||
<li>Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.</li>
|
||||
<li>Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.</li>
|
||||
<li>Agents/Pi: treat accepted embedded <code>sessions_spawn</code> child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.</li>
|
||||
<li>CLI/models: resolve <code>openclaw models set</code> aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.</li>
|
||||
<li>Doctor: show personal Codex CLI asset notices as info instead of warnings. Fixes #84859.</li>
|
||||
<li>WhatsApp: update Baileys to <code>7.0.0-rc13</code> and drop the obsolete logger type patch.</li>
|
||||
<li>CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring <code>openclaw update --tag main</code> for one-off package updates. (#81296) Thanks @fuller-stack-dev.</li>
|
||||
<li>Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.</li>
|
||||
<li>Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.</li>
|
||||
<li>Infra/json: retry transient <code>File changed during read</code> races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)</li>
|
||||
<li>Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.</li>
|
||||
<li>Providers/Ollama: resolve configured Ollama Cloud <code>OLLAMA_API_KEY</code> markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)</li>
|
||||
<li>Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.</li>
|
||||
<li>Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.</li>
|
||||
<li>Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in <code>openclaw status --all</code>. Fixes #49577. (#72724)</li>
|
||||
<li>Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.</li>
|
||||
<li>Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so <code>ollama-local</code> marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.</li>
|
||||
<li>QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.</li>
|
||||
<li>Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Installer: require a real controlling terminal before launching onboarding so headless <code>curl | bash</code> installs finish cleanly after installing the CLI.</li>
|
||||
<li>Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.</li>
|
||||
<li>Codex app-server: keep interrupted turn statuses from being treated as OpenClaw aborts by themselves, so tool-only turns remain eligible for no-visible-answer recovery. Fixes #84492.</li>
|
||||
<li>Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.</li>
|
||||
<li>Control UI/Web Push: use <code>https://openclaw.ai</code> as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when <code>OPENCLAW_VAPID_SUBJECT</code> is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.</li>
|
||||
<li>Control UI: distinguish inherited thinking-off settings from explicit Off selections so the thinking selector no longer shows two identical Off rows. (#85223) Thanks @amknight.</li>
|
||||
<li>Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.</li>
|
||||
<li>Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.</li>
|
||||
<li>Memory/search: stop recall tracking from writing dreaming side-effect artifacts when <code>dreaming.enabled=false</code>, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.</li>
|
||||
<li>Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.</li>
|
||||
<li>QA: keep <code>pnpm qa:e2e</code> self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.</li>
|
||||
<li>fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.</li>
|
||||
<li>doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.</li>
|
||||
<li>Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.</li>
|
||||
<li>Discord: keep session recovery and <code>/stop</code> abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.</li>
|
||||
<li>Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.</li>
|
||||
<li>Codex app-server: give visible <code>message</code> dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.</li>
|
||||
<li>Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.</li>
|
||||
<li>Matrix: keep explicitly configured two-person rooms on the room route before stale <code>m.direct</code> or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from <code>agents_list</code> and rejected by <code>sessions_spawn</code>. Fixes #84811. (#85154) Thanks @joshavant.</li>
|
||||
<li>PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.</li>
|
||||
<li>Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.</li>
|
||||
<li>Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.</li>
|
||||
<li>Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.</li>
|
||||
<li>Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.</li>
|
||||
<li>Node/Linux: keep <code>OPENCLAW_GATEWAY_TOKEN</code> out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)</li>
|
||||
<li>Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale <code>dreaming-narrative-*</code> sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.</li>
|
||||
<li>Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.</li>
|
||||
<li>TUI: coalesce repeated idle Esc abort notices into a single <code>no active run xN</code> system row instead of appending duplicate rows.</li>
|
||||
<li>Telegram: honor <code>channels.telegram.pollingStallThresholdMs</code> in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.</li>
|
||||
<li>Telegram: dedupe replayed message dispatches by Telegram chat/message identity so isolated-ingress replays do not trigger duplicate model dispatches. Fixes #84886. (#85208) Thanks @joshavant.</li>
|
||||
<li>Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.</li>
|
||||
<li>Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.</li>
|
||||
<li>Slack: keep native plugin approval prompts in the originating app conversation thread when the live Slack turn source is a <code>D...</code> conversation.</li>
|
||||
<li>Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.</li>
|
||||
<li>CLI/perf: keep <code>setup --help</code>, <code>onboard --help</code>, and <code>configure --help</code> out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: keep <code>agents --help</code> out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: keep <code>secrets --help</code> and <code>nodes --help</code> on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.</li>
|
||||
<li>CLI/perf: serve <code>doctor</code>, <code>gateway</code>, <code>models</code>, and <code>plugins</code> parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.</li>
|
||||
<li>Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.</li>
|
||||
<li>Codex: keep heartbeat response tool schemas durable without exposing dynamic tools disabled by turn policy, so heartbeat wakeups can reuse threads while scoped tool allowlists stay enforced. (#84681) Thanks @jalehman.</li>
|
||||
<li>Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.</li>
|
||||
<li>Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with <code>No API key found for provider "openai-codex"</code> until the user runs <code>openclaw doctor</code>. Thanks @Totalsolutionsync and @romneyda.</li>
|
||||
<li>Codex/failover: classify <code>deactivated_workspace</code> as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.</li>
|
||||
<li>Exec: keep configured <code>tools.exec.pathPrepend</code> entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.</li>
|
||||
<li>Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.</li>
|
||||
<li>Agents/embedded runner: classify HTML auth provider responses as <code>auth_html</code> and return a re-authentication hint instead of the CDN-blocked copy that <code>upstream_html</code> returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.</li>
|
||||
<li>TUI/streaming watchdog: dismiss the <code>This response is taking longer than expected</code> notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.</li>
|
||||
<li>Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.20</title>
|
||||
<pubDate>Thu, 21 May 2026 21:19:52 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052090</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.20</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.20</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Exec approvals: remove the old <code>cat SKILL.md && printf ... && <skill-wrapper></code> allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.</li>
|
||||
<li>Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord/voice: include bounded <code>IDENTITY.md</code>, <code>USER.md</code>, and <code>SOUL.md</code> profile context in realtime voice session instructions by default, with <code>voice.realtime.bootstrapContextFiles: []</code> available to disable it. (#84499) Thanks @fuller-stack-dev.</li>
|
||||
<li>Dependencies: bump the bundled Codex harness to <code>@openai/codex</code> <code>0.132.0</code> and refresh the app-server model-list docs for the new catalog.</li>
|
||||
<li>CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.</li>
|
||||
<li>Agents/config: allow <code>agents.list[].experimental.localModelLean</code> so lean local-model mode can be enabled for one configured agent instead of globally.</li>
|
||||
<li>Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/OpenRouter: honor provider-level <code>params.provider</code> routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI/tasks: include stale-running task maintenance decisions in <code>openclaw tasks maintenance --json</code> so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.</li>
|
||||
<li>Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.</li>
|
||||
<li>Providers/MiniMax music: stop advertising <code>durationSeconds</code> control and remove prompt-injected duration hints, so <code>music_generate</code> reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.</li>
|
||||
<li>Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.</li>
|
||||
<li>WhatsApp: update Baileys to <code>7.0.0-rc12</code>.</li>
|
||||
<li>Build: suppress per-locale <code>rolldown-plugin-dts:fake-js</code> CommonJS dts warnings emitted while bundling the intentionally-inlined <code>zod/v4/locales/*.d.cts</code> files, so <code>pnpm build</code> output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.</li>
|
||||
<li>CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode <code>openclaw nodes</code> commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.</li>
|
||||
<li>Approvals: route manual <code>/approve</code> decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.</li>
|
||||
<li>Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.</li>
|
||||
<li>Dependencies: update <code>@openclaw/fs-safe</code> to <code>0.2.7</code> so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.</li>
|
||||
<li>Infra/secrets: restore the fail-closed contract for <code>tryReadSecretFileSync</code> so credential loaders that pass <code>rejectSymlink: true</code> (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.</li>
|
||||
<li>Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)</li>
|
||||
<li>Doctor: remove unrecognized <code>models.providers.*.models[*].compat.thinkingFormat</code> values during <code>doctor --fix</code> so stale provider model config can validate after upgrade. Fixes #77803.</li>
|
||||
<li>Doctor: warn when <code>openclaw.json</code> stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.</li>
|
||||
<li>Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from <code>agents.defaults.model.primary</code>.</li>
|
||||
<li>WebChat: clear stale typing indicators when session change events mark the active chat run complete.</li>
|
||||
<li>Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.</li>
|
||||
<li>macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.</li>
|
||||
<li>Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.</li>
|
||||
<li>fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.</li>
|
||||
<li>Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.</li>
|
||||
<li>CLI: keep the private QA subcommand out of exported command descriptors unless <code>OPENCLAW_ENABLE_PRIVATE_QA_CLI=1</code>, so root help and subcommand markers match runtime registration. (#84519)</li>
|
||||
<li>CLI/cron: bound <code>openclaw cron show</code> job lookup pagination so non-advancing or unbounded <code>cron.list</code> responses fail instead of hanging the command. Fixes #83856. (#83989)</li>
|
||||
<li>Agents/messages: stop message-tool-only turns after a successful source-channel <code>message</code> send while keeping transcript mirrors under the session write lock. (#84289)</li>
|
||||
<li>Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving <code>strict=false</code> fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.</li>
|
||||
<li>Agents/code mode: spell out the <code>exec</code> tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.</li>
|
||||
<li>Codex: give <code>image_generate</code> dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.</li>
|
||||
<li>Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.</li>
|
||||
<li>CLI/message: include a stable top-level <code>messageId</code> in <code>openclaw message --json</code> output when channel sends return one. (#84191) Thanks @100menotu001.</li>
|
||||
<li>Cron: preserve legacy top-level array <code>jobs.json</code> stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.</li>
|
||||
<li>Gateway/agents: use an agent's <code>identity.name</code> in Gateway agent summaries when <code>agents.list[].name</code> is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.</li>
|
||||
<li>Channels/replies: keep normal <code>/verbose</code> failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.</li>
|
||||
<li>Plugins/hooks: apply a default 30-second timeout to <code>before_compaction</code> and <code>after_compaction</code> hooks so a hung plugin handler no longer blocks compaction completion. (#84153)</li>
|
||||
<li>Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.</li>
|
||||
<li>Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.</li>
|
||||
<li>Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.</li>
|
||||
<li>Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.</li>
|
||||
<li>Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.</li>
|
||||
<li>Plugins/perf: thread explicit plugin discovery results through <code>loadBundledCapabilityRuntimeRegistry</code>, <code>resolveBundledPluginSources</code>, and <code>listChannelCatalogEntries</code> so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.</li>
|
||||
<li>harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.</li>
|
||||
<li>Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.</li>
|
||||
<li>CLI/channels: preserve the first line of <code>openclaw channels logs</code> output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed <code>readLogSlice</code> behavior in <code>src/logging/log-tail.ts</code>.</li>
|
||||
<li>Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)</li>
|
||||
<li>CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.</li>
|
||||
<li>Matrix/config: accept <code>messages.queue.byChannel.matrix</code> queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.</li>
|
||||
<li>CLI: format <code>openclaw acp client</code> failures through the shared error formatter so object-shaped errors stay readable instead of printing <code>[object Object]</code>. Fixes #83904. (#84080)</li>
|
||||
<li>Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when <code>/api/show</code> omits capabilities. (#84055) Thanks @dutifulbob.</li>
|
||||
<li>Installer/Windows: launch <code>install.ps1</code> onboarding as an attached child process so fresh native Windows installs do not freeze visibly at <code>Starting setup...</code> or corrupt the wizard's terminal rendering.</li>
|
||||
<li>CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so <code>openclaw update</code> no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.</li>
|
||||
<li>CLI/gateway: include the running Gateway version in <code>gateway status</code> JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.</li>
|
||||
<li>Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.</li>
|
||||
<li>CLI/nodes: request pending node surface approval scopes before <code>openclaw nodes approve</code> so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with <code>missing scope: operator.admin</code>. (#84392) Thanks @joshavant.</li>
|
||||
<li>Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.</li>
|
||||
<li>Agents: include bounded trajectory queued-writer diagnostics in <code>pi-trajectory-flush</code> timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.</li>
|
||||
<li>Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as <code>anthropic/opus-4.7</code> no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.</li>
|
||||
<li>Agents: honor explicit <code>models.providers.<id>.timeoutSeconds</code> values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.</li>
|
||||
<li>Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing <code>reason=no_active_run</code> queue noise. (#82964) Thanks @galiniliev.</li>
|
||||
<li>CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.</li>
|
||||
<li>Media: decode URL path basenames before using them as remote media fallback filenames, so files like <code>My%20Report.pdf</code> are surfaced as <code>My Report.pdf</code>. Fixes #84050. (#84052) Thanks @jbetala7.</li>
|
||||
<li>WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to <code>channels.whatsapp.groups</code> without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.</li>
|
||||
<li>WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.</li>
|
||||
<li>CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.</li>
|
||||
<li>Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.</li>
|
||||
<li>iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.</li>
|
||||
<li>Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.</li>
|
||||
<li>Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.</li>
|
||||
<li>Codex: respect explicit <code>models auth order set</code> and <code>config.auth.order</code> precedence over stale <code>lastGood</code> in <code>/codex account</code>, and show <code>no working credential</code> when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.</li>
|
||||
<li>Agents: honor <code>messages.suppressToolErrors</code> for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.</li>
|
||||
<li>Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.20/OpenClaw-2026.5.20.zip" length="54396392" type="application/octet-stream" sparkle:edSignature="Ufz+twYjgj5NDg29tG3Ttx/JNyT3/a3EKLciBGvsa38C6Dwqp4yFYC5jSBiSlubwBXhrq8OQDMgavMKtSsclBQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026052801
|
||||
versionName = "2026.5.28"
|
||||
versionCode = 2026053001
|
||||
versionName = "2026.5.30"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.30 - 2026-05-30
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
## 2026.5.28 - 2026-05-28
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.28
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.28
|
||||
OPENCLAW_IOS_VERSION = 2026.5.30
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.30
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -73,9 +73,10 @@ Release behavior:
|
||||
- Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway.
|
||||
- See `apps/ios/VERSIONING.md` for the full workflow.
|
||||
|
||||
Required env for beta builds:
|
||||
Relay behavior for beta builds:
|
||||
|
||||
- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
- Beta builds default to `https://ios-push-relay.openclaw.ai`.
|
||||
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||
|
||||
Archive without upload:
|
||||
@@ -118,7 +119,7 @@ scripts/ios-asc-keychain-setup.sh \
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Set the official/TestFlight relay URL for the build:
|
||||
3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`.
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
|
||||
12
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/Contents.json
vendored
Normal file
12
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "openclaw-icon.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/openclaw-icon.png
vendored
Normal file
BIN
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/openclaw-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
@@ -1,47 +0,0 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: OpenClawChatViewModel
|
||||
private let userAccent: Color?
|
||||
private let agentName: String?
|
||||
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
self._viewModel = State(
|
||||
initialValue: OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: transport))
|
||||
self.userAccent = userAccent
|
||||
self.agentName = agentName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
OpenClawChatView(
|
||||
viewModel: self.viewModel,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: self.userAccent)
|
||||
.navigationTitle(self.chatTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chatTitle: String {
|
||||
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "Chat" }
|
||||
return "Chat (\(trimmed))"
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,162 @@ import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
private struct CreateSessionParams: Codable {
|
||||
var key: String
|
||||
var label: String?
|
||||
var parentSessionKey: String?
|
||||
}
|
||||
|
||||
private struct RunParams: Codable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private struct ListSessionsParams: Codable {
|
||||
var includeGlobal: Bool
|
||||
var includeUnknown: Bool
|
||||
var limit: Int?
|
||||
}
|
||||
|
||||
private struct SessionKeyParams: Codable {
|
||||
var key: String
|
||||
}
|
||||
|
||||
private struct ChatSendParams: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
var timeoutMs: Int
|
||||
var idempotencyKey: String
|
||||
}
|
||||
|
||||
private struct AgentWaitParams: Codable {
|
||||
var runId: String
|
||||
var timeoutMs: Int
|
||||
}
|
||||
|
||||
private struct AgentWaitResponse: Codable {
|
||||
var runId: String?
|
||||
var status: String?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
struct AgentWaitCompletion: Equatable {
|
||||
var runId: String
|
||||
var status: String
|
||||
var completed: Bool
|
||||
}
|
||||
|
||||
static func isAgentWaitCompletionStatus(_ status: String) -> Bool {
|
||||
switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "ok", "completed", "success", "succeeded":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init(gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int {
|
||||
max(1, Int(ceil(Double(timeoutMs) / 1000.0)) + 5)
|
||||
}
|
||||
|
||||
static func makeListSessionsParamsJSON(limit: Int?) throws -> String {
|
||||
try self.encodeParams(ListSessionsParams(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
}
|
||||
|
||||
static func makeChatSendParamsJSON(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) throws -> String
|
||||
{
|
||||
let params = ChatSendParams(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
timeoutMs: self.defaultChatSendTimeoutMs,
|
||||
idempotencyKey: idempotencyKey)
|
||||
return try self.encodeParams(params)
|
||||
}
|
||||
|
||||
static func decodeAgentWaitCompletion(_ data: Data, fallbackRunId: String) throws -> AgentWaitCompletion {
|
||||
let decoded = try JSONDecoder().decode(AgentWaitResponse.self, from: data)
|
||||
let status = (decoded.status ?? "unknown").lowercased()
|
||||
return AgentWaitCompletion(
|
||||
runId: decoded.runId ?? fallbackRunId,
|
||||
status: status,
|
||||
completed: self.isAgentWaitCompletionStatus(status))
|
||||
}
|
||||
|
||||
private static func makeCreateSessionParamsJSON(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) throws -> String
|
||||
{
|
||||
let params = CreateSessionParams(
|
||||
key: key,
|
||||
label: label,
|
||||
parentSessionKey: parentSessionKey)
|
||||
return try self.encodeParams(params)
|
||||
}
|
||||
|
||||
private static func makeRunParamsJSON(sessionKey: String, runId: String) throws -> String {
|
||||
try self.encodeParams(RunParams(sessionKey: sessionKey, runId: runId))
|
||||
}
|
||||
|
||||
private static func makeSessionKeyParamsJSON(_ sessionKey: String) throws -> String {
|
||||
try self.encodeParams(SessionKeyParams(key: sessionKey))
|
||||
}
|
||||
|
||||
private static func makeHistoryParamsJSON(sessionKey: String) throws -> String {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
return try self.encodeParams(Params(sessionKey: sessionKey))
|
||||
}
|
||||
|
||||
private static func makeAgentWaitParamsJSON(runId: String, timeoutMs: Int) throws -> String {
|
||||
try self.encodeParams(AgentWaitParams(runId: runId, timeoutMs: timeoutMs))
|
||||
}
|
||||
|
||||
private static func encodeParams(_ params: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw EncodingError.invalidValue(
|
||||
params,
|
||||
EncodingError.Context(codingPath: [], debugDescription: "Encoded gateway params were not UTF-8"))
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
return json
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
let json = try Self.makeCreateSessionParamsJSON(
|
||||
key: key,
|
||||
label: label,
|
||||
parentSessionKey: parentSessionKey)
|
||||
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
let json = try Self.makeRunParamsJSON(sessionKey: sessionKey, runId: runId)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
struct Params: Codable {
|
||||
var includeGlobal: Bool
|
||||
var includeUnknown: Bool
|
||||
var limit: Int?
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeListSessionsParamsJSON(limit: limit)
|
||||
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
@@ -40,23 +172,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
||||
}
|
||||
@@ -73,35 +199,52 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
var timeoutMs: Int
|
||||
var idempotencyKey: String
|
||||
}
|
||||
|
||||
let params = Params(
|
||||
GatewayDiagnostics.log(startLogMessage)
|
||||
let json = try Self.makeChatSendParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
timeoutMs: 30000,
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
do {
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
|
||||
return decoded
|
||||
} catch {
|
||||
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
|
||||
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !runId.isEmpty else { return false }
|
||||
|
||||
do {
|
||||
let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs)
|
||||
let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs)
|
||||
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
|
||||
let res = try await self.gateway.request(
|
||||
method: "agent.wait",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: requestTimeoutSeconds)
|
||||
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
|
||||
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
|
||||
if !completion.completed {
|
||||
Self.logger.warning(
|
||||
"agent.wait status \(completion.status, privacy: .public) runId=\(runId, privacy: .public)")
|
||||
}
|
||||
return completion.completed
|
||||
} catch {
|
||||
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
|
||||
690
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
690
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
@@ -0,0 +1,690 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
let dreamingValue: String
|
||||
let dreamingDetail: String
|
||||
let dreamingColor: Color
|
||||
let refresh: () async -> Void
|
||||
@State private var selectedDreamDiaryDayID: String?
|
||||
@State private var dreamActionBusy: DreamAction?
|
||||
@State private var dreamActionStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
value: self.dreamingValue,
|
||||
detail: self.dreamingDetail,
|
||||
color: self.dreamingColor)
|
||||
self.dreamingTotalsCard
|
||||
self.dreamingActionsCard
|
||||
self.dreamDiaryCard
|
||||
self.dreamingEntriesList(
|
||||
title: "Promoted Entries",
|
||||
entries: self.overview?.dreaming?.promotedEntries ?? [],
|
||||
emptyTitle: "No promoted entries",
|
||||
emptyDetail: "Dreaming has not promoted durable memory entries yet.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Signal Entries",
|
||||
entries: self.overview?.dreaming?.signalEntries ?? [],
|
||||
emptyTitle: "No signal entries",
|
||||
emptyDetail: "No recent recall, daily, grounded, or phase signals were reported.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Short-Term Recall",
|
||||
entries: self.overview?.dreaming?.shortTermEntries ?? [],
|
||||
emptyTitle: "No short-term entries",
|
||||
emptyDetail: "The short-term dreaming store is empty.")
|
||||
self.dreamingPhasesCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Dreaming")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
case dedupe
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .backfill: "Backfill"
|
||||
case .repair: "Repair"
|
||||
case .dedupe: "Dedupe"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .backfill: "book.pages"
|
||||
case .repair: "wrench.and.screwdriver"
|
||||
case .dedupe: "square.stack.3d.down.right"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .backfill: "doctor.memory.backfillDreamDiary"
|
||||
case .repair: "doctor.memory.repairDreamingArtifacts"
|
||||
case .dedupe: "doctor.memory.dedupeDreamDiary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingTotalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Memory State")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.dreamingValue, color: self.dreamingColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(
|
||||
label: "Short-term",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.shortTermCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Signals",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.totalSignalCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Promoted",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.promotedToday ?? 0))
|
||||
}
|
||||
if let storeError = self.normalized(self.overview?.dreaming?.storeError) {
|
||||
Text(storeError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingActionsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Maintenance")
|
||||
.font(.headline)
|
||||
Text("Refresh reads live state. Maintenance actions update the gateway diary/artifacts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.refresh() }
|
||||
} label: {
|
||||
Image(systemName: self.overviewLoading ? "hourglass" : "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.overviewLoading)
|
||||
.accessibilityLabel("Refresh dreaming")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(DreamAction.allCases) { action in
|
||||
Button {
|
||||
Task { await self.runDreamAction(action) }
|
||||
} label: {
|
||||
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.gatewayConnected || self.dreamActionBusy != nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let dreamActionStatusText {
|
||||
Text(dreamActionStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamDiaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dream Diary")
|
||||
ProCard(padding: 0) {
|
||||
if let diary = self.overview?.dreamDiary {
|
||||
if diary.found, let content = self.normalizedMultiline(diary.content) {
|
||||
let days = Self.dreamDiaryDays(from: content)
|
||||
let selectedDay = self.selectedDreamDiaryDay(from: days)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: "book.pages", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diary.path)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.dreamDiaryUpdatedLabel(diary))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if !days.isEmpty {
|
||||
self.dreamDiaryDayMenu(days: days, selectedDay: selectedDay)
|
||||
}
|
||||
}
|
||||
if let selectedDay {
|
||||
self.dreamDiaryDayView(selectedDay)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "calendar.badge.exclamationmark",
|
||||
title: "No day entries",
|
||||
detail: "The diary is present, but it does not contain dated Dream Diary blocks.")
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: diary.found ? "Dream diary is empty" : "No dream diary yet",
|
||||
detail: diary.found
|
||||
? "\(diary.path) exists but has no readable content."
|
||||
: "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.")
|
||||
.padding(14)
|
||||
}
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: self.gatewayConnected ? "Diary unavailable" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dream diary content."
|
||||
: "Connect a gateway to read dream diary entries.")
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamDiaryDayMenu(days: [DreamDiaryDay], selectedDay: DreamDiaryDay?) -> some View {
|
||||
Menu {
|
||||
ForEach(Array(days.reversed())) { day in
|
||||
Button {
|
||||
self.selectedDreamDiaryDayID = day.id
|
||||
} label: {
|
||||
Label(
|
||||
day.title,
|
||||
systemImage: day.id == selectedDay?.id ? "checkmark.circle.fill" : "calendar")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
Text(selectedDay?.title ?? "Day")
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 34)
|
||||
.background(Color.primary.opacity(0.055), in: Capsule())
|
||||
}
|
||||
.accessibilityLabel("Dream diary day")
|
||||
}
|
||||
|
||||
private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(day.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
Text(day.body)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(120)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func selectedDreamDiaryDay(from days: [DreamDiaryDay]) -> DreamDiaryDay? {
|
||||
if let selectedDreamDiaryDayID,
|
||||
let match = days.first(where: { $0.id == selectedDreamDiaryDayID })
|
||||
{
|
||||
return match
|
||||
}
|
||||
return days.last
|
||||
}
|
||||
|
||||
private func dreamingEntriesList(
|
||||
title: String,
|
||||
entries: [DreamingEntryLite],
|
||||
emptyTitle: String,
|
||||
emptyDetail: String) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard(padding: 0) {
|
||||
if entries.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "doc.text.magnifyingglass",
|
||||
title: emptyTitle,
|
||||
detail: self.gatewayConnected ? emptyDetail : "Connect a gateway to load dreaming entries.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
self.dreamingEntryRow(entry)
|
||||
if index < entries.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingEntryRow(_ entry: DreamingEntryLite) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: "text.page", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.dreamingEntryTitle(entry))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(entry.snippet)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
Text(self.dreamingEntryDetail(entry))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text("\(entry.totalSignalCount)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private var dreamingPhasesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Phases")
|
||||
ProCard(padding: 0) {
|
||||
let phases = self.dreamingPhases
|
||||
if phases.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "moon.zzz",
|
||||
title: self.gatewayConnected ? "No phase status" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dreaming phase details."
|
||||
: "Connect a gateway to load dreaming phases.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(phases.enumerated()), id: \.element.id) { index, phase in
|
||||
self.dreamingPhaseRow(phase)
|
||||
if index < phases.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var dreamingPhases: [DreamingPhaseRow] {
|
||||
let phaseOrder = ["light", "deep", "rem"]
|
||||
let phases = self.overview?.dreaming?.phases ?? [:]
|
||||
return phaseOrder.compactMap { id in
|
||||
guard let phase = phases[id] else { return nil }
|
||||
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingPhaseRow(_ phase: DreamingPhaseRow) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: phase.status.enabled == false ? "pause.circle" : "moon.stars",
|
||||
color: phase.status.enabled == false ? .secondary : OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(phase.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.dreamingPhaseDetail(phase.status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let cron = self.normalized(phase.status.cron) {
|
||||
Text(cron)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.dreamingPhaseState(phase.status))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(phase.status.managedCronPresent == true ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func dreamingEntryTitle(_ entry: DreamingEntryLite) -> String {
|
||||
let path = entry.path.split(separator: "/").last.map(String.init) ?? entry.path
|
||||
return "\(path):\(entry.startLine)"
|
||||
}
|
||||
|
||||
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
|
||||
let parts = [
|
||||
entry.promotedAt.map { "promoted \($0)" },
|
||||
entry.lastRecalledAt.map { "recalled \($0)" },
|
||||
"\(entry.recallCount) recalls",
|
||||
"\(entry.groundedCount) grounded",
|
||||
].compactMap(\.self)
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if let nextRunAtMs = phase.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if phase.managedCronPresent == true {
|
||||
return "Managed cron is installed."
|
||||
}
|
||||
return "Managed cron is not installed."
|
||||
}
|
||||
|
||||
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if phase.enabled == false { return "off" }
|
||||
return phase.managedCronPresent == true ? "scheduled" : "setup"
|
||||
}
|
||||
|
||||
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
|
||||
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
|
||||
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runDreamAction(_ action: DreamAction) async {
|
||||
guard self.gatewayConnected, self.dreamActionBusy == nil else { return }
|
||||
self.dreamActionBusy = action
|
||||
self.dreamActionStatusText = nil
|
||||
defer { self.dreamActionBusy = nil }
|
||||
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: action.method,
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 30)
|
||||
self.dreamActionStatusText = Self.dreamActionSummary(action: action, data: data)
|
||||
await self.refresh()
|
||||
} catch {
|
||||
self.dreamActionStatusText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
let written = json["written"] as? Int
|
||||
let replaced = json["replaced"] as? Int
|
||||
let removed = json["removedEntries"] as? Int
|
||||
let changed = json["changed"] as? Bool
|
||||
let parts = [
|
||||
written.map { "\($0) written" },
|
||||
replaced.map { "\($0) replaced" },
|
||||
removed.map { "\($0) removed" },
|
||||
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
|
||||
].compactMap(\.self)
|
||||
if parts.isEmpty {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
return "\(action.title): \(parts.joined(separator: ", "))."
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func normalizedMultiline(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
private static func dreamDiaryDays(from content: String) -> [DreamDiaryDay] {
|
||||
let inner = Self.dreamDiaryInnerContent(content)
|
||||
let separatorBlocks = inner
|
||||
.components(separatedBy: "\n---")
|
||||
.flatMap { $0.components(separatedBy: "\r\n---") }
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let blocks = separatorBlocks.count > 1 ? separatorBlocks : Self.splitDiaryBlocksByDateLine(inner)
|
||||
let parsedBlocks = blocks.enumerated().map { index, block in
|
||||
Self.dreamDiaryBlock(from: block, index: index)
|
||||
}.filter(\.hasDatedEntry)
|
||||
return Self.mergeDiaryBlocksByDay(parsedBlocks)
|
||||
}
|
||||
|
||||
private static func dreamDiaryInnerContent(_ content: String) -> String {
|
||||
let start = "<!-- openclaw:dreaming:diary:start -->"
|
||||
let end = "<!-- openclaw:dreaming:diary:end -->"
|
||||
guard let startRange = content.range(of: start),
|
||||
let endRange = content.range(of: end, range: startRange.upperBound..<content.endIndex)
|
||||
else {
|
||||
return content
|
||||
}
|
||||
return String(content[startRange.upperBound..<endRange.lowerBound])
|
||||
}
|
||||
|
||||
private static func dreamDiaryBlock(from block: String, index: Int) -> DreamDiaryDay {
|
||||
let rawLines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let dateLineIndex = rawLines.firstIndex { line in
|
||||
Self.isDiaryDateLine(line)
|
||||
}
|
||||
let markerDay = rawLines.compactMap(Self.backfillDay).first
|
||||
let rawTitle = dateLineIndex.flatMap { Self.unwrappedEmphasis(rawLines[$0]) } ?? markerDay
|
||||
let title = rawTitle.map(Self.dayTitle) ?? markerDay ?? "Diary"
|
||||
let id = markerDay ?? Self.dayID(title)
|
||||
let bodyLines = rawLines.enumerated().compactMap { offset, line -> String? in
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if offset == dateLineIndex { return nil }
|
||||
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return nil }
|
||||
if trimmed == "#" || trimmed == "# Dream Diary" { return nil }
|
||||
return line
|
||||
}
|
||||
let body = bodyLines
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return DreamDiaryDay(
|
||||
id: id.isEmpty ? "\(index)" : id,
|
||||
title: title,
|
||||
body: body.isEmpty ? "No diary prose for this day." : body,
|
||||
entryCount: 1,
|
||||
hasDatedEntry: rawTitle != nil)
|
||||
}
|
||||
|
||||
private static func mergeDiaryBlocksByDay(_ blocks: [DreamDiaryDay]) -> [DreamDiaryDay] {
|
||||
var ordered: [DreamDiaryDay] = []
|
||||
for block in blocks {
|
||||
if let existingIndex = ordered.firstIndex(where: { $0.title == block.title }) {
|
||||
let existing = ordered[existingIndex]
|
||||
ordered[existingIndex] = DreamDiaryDay(
|
||||
id: existing.id,
|
||||
title: existing.title,
|
||||
body: [existing.body, block.body].joined(separator: "\n\n---\n\n"),
|
||||
entryCount: existing.entryCount + block.entryCount,
|
||||
hasDatedEntry: true)
|
||||
} else {
|
||||
ordered.append(block)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func splitDiaryBlocksByDateLine(_ content: String) -> [String] {
|
||||
var blocks: [String] = []
|
||||
var current: [String] = []
|
||||
for line in content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if Self.isDiaryDateLine(line), !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
current = []
|
||||
}
|
||||
current.append(line)
|
||||
}
|
||||
if !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
}
|
||||
return blocks
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func isDiaryDateLine(_ line: String) -> Bool {
|
||||
guard let value = unwrappedEmphasis(line) else { return false }
|
||||
let monthNames = "January|February|March|April|May|June|July|August|September|October|November|December"
|
||||
let monthDatePattern = #"\b("# + monthNames + #")\s+\d{1,2},\s+\d{4}\b"#
|
||||
let isoDatePattern = #"\b\d{4}-\d{2}-\d{2}\b"#
|
||||
return value.range(
|
||||
of: "\(monthDatePattern)|\(isoDatePattern)",
|
||||
options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func dayTitle(_ rawTitle: String) -> String {
|
||||
let noTime = rawTitle.replacingOccurrences(
|
||||
of: #"\s+at\s+\d{1,2}:\d{2}.*$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return noTime.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func dayID(_ title: String) -> String {
|
||||
title.lowercased()
|
||||
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
}
|
||||
|
||||
private static func unwrappedEmphasis(_ line: String) -> String? {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("*"), trimmed.hasSuffix("*"), trimmed.count > 2 else { return nil }
|
||||
return String(trimmed.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private static func backfillDay(_ line: String) -> String? {
|
||||
guard let range = line.range(of: #"day=\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[range].dropFirst(4))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamDiaryDay: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let entryCount: Int
|
||||
let hasDatedEntry: Bool
|
||||
}
|
||||
368
apps/ios/Sources/Design/AgentProModels.swift
Normal file
368
apps/ios/Sources/Design/AgentProModels.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
enum AgentProValueReader {
|
||||
static func intValue(_ value: AnyCodable?) -> Int? {
|
||||
switch value?.value {
|
||||
case let int as Int: int
|
||||
case let double as Double where double.isFinite: Int(double)
|
||||
case let string as String: Int(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
static func doubleValue(_ value: AnyCodable?) -> Double? {
|
||||
switch value?.value {
|
||||
case let double as Double where double.isFinite: double
|
||||
case let int as Int: Double(int)
|
||||
case let string as String: Double(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOverviewSnapshot {
|
||||
let skills: SkillStatusReportLite?
|
||||
let presence: [PresenceEntry]
|
||||
let cronStatus: CronStatusLite?
|
||||
let cronJobs: [CronJob]
|
||||
let dreaming: DreamingStatusLite?
|
||||
let dreamDiary: DreamDiaryLite?
|
||||
let usage: CostUsageSummaryLite?
|
||||
let activeAgentId: String
|
||||
let agentSkillFilter: [String]?
|
||||
let loadedAt: Date
|
||||
|
||||
var hasAnyLiveData: Bool {
|
||||
self.skills != nil
|
||||
|| !self.presence.isEmpty
|
||||
|| self.cronStatus != nil
|
||||
|| !self.cronJobs.isEmpty
|
||||
|| self.dreaming != nil
|
||||
|| self.dreamDiary != nil
|
||||
|| self.usage != nil
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusReportLite: Decodable {
|
||||
let workspaceDir: String?
|
||||
let managedSkillsDir: String?
|
||||
let agentId: String?
|
||||
let agentSkillFilter: [String]?
|
||||
let skills: [SkillStatusEntryLite]
|
||||
|
||||
var totalCount: Int {
|
||||
self.skills.count
|
||||
}
|
||||
|
||||
var enabledCount: Int {
|
||||
self.skills.count {
|
||||
$0.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var blockedCount: Int {
|
||||
self.skills.count {
|
||||
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
|
||||
}
|
||||
}
|
||||
|
||||
var missingRequirementCount: Int {
|
||||
self.skills.count {
|
||||
$0.hasMissingRequirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusEntryLite: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let source: String?
|
||||
let filePath: String?
|
||||
let skillKey: String?
|
||||
let primaryEnv: String?
|
||||
let emoji: String?
|
||||
let homepage: String?
|
||||
let disabled: Bool?
|
||||
let blockedByAllowlist: Bool?
|
||||
let blockedByAgentFilter: Bool?
|
||||
let missing: SkillStatusMissingLite?
|
||||
let install: [SkillInstallOptionLite]?
|
||||
|
||||
var displayName: String {
|
||||
if let emoji, !emoji.isEmpty {
|
||||
return "\(emoji) \(self.name)"
|
||||
}
|
||||
return self.name
|
||||
}
|
||||
|
||||
var effectiveSkillKey: String {
|
||||
let trimmed = (self.skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? self.name : trimmed
|
||||
}
|
||||
|
||||
var isGloballyEnabled: Bool {
|
||||
self.disabled != true
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
self.disabled != true
|
||||
&& self.blockedByAllowlist != true
|
||||
&& self.blockedByAgentFilter != true
|
||||
}
|
||||
|
||||
var hasMissingRequirements: Bool {
|
||||
guard let missing else { return false }
|
||||
return !missing.bins.isEmpty
|
||||
|| !missing.env.isEmpty
|
||||
|| !missing.config.isEmpty
|
||||
|| !missing.os.isEmpty
|
||||
}
|
||||
|
||||
var missingSummary: String? {
|
||||
guard let missing else { return nil }
|
||||
let values = [
|
||||
missing.bins,
|
||||
missing.env,
|
||||
missing.config,
|
||||
missing.os,
|
||||
].flatMap(\.self)
|
||||
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
|
||||
}
|
||||
|
||||
var installSummary: String? {
|
||||
guard let option = self.install?.first else { return nil }
|
||||
return option.label
|
||||
}
|
||||
|
||||
var missingBins: [String] {
|
||||
self.missing?.bins ?? []
|
||||
}
|
||||
|
||||
var homepageURL: URL? {
|
||||
guard let homepage else { return nil }
|
||||
return URL(string: homepage)
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillInstallOptionLite: Decodable {
|
||||
let id: String?
|
||||
let kind: String?
|
||||
let label: String
|
||||
let bins: [String]?
|
||||
}
|
||||
|
||||
struct SkillUpdateParams: Encodable {
|
||||
let skillKey: String
|
||||
var enabled: Bool?
|
||||
var apiKey: String?
|
||||
}
|
||||
|
||||
struct SkillInstallParams: Encodable {
|
||||
let name: String
|
||||
let installId: String
|
||||
let timeoutMs: Int
|
||||
}
|
||||
|
||||
struct SkillInstallResultLite: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct ClawHubSearchParams: Encodable {
|
||||
let query: String?
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
struct ClawHubSearchResponseLite: Decodable {
|
||||
let results: [ClawHubSearchResultLite]
|
||||
}
|
||||
|
||||
struct ClawHubSearchResultLite: Decodable {
|
||||
let slug: String
|
||||
let displayName: String
|
||||
let summary: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct ClawHubInstallParams: Encodable {
|
||||
let source = "clawhub"
|
||||
let slug: String
|
||||
}
|
||||
|
||||
struct CronRunParams: Encodable {
|
||||
let id: String
|
||||
let mode: String
|
||||
}
|
||||
|
||||
struct CronUpdatePatch: Encodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
struct CronUpdateParams: Encodable {
|
||||
let id: String
|
||||
let patch: CronUpdatePatch
|
||||
}
|
||||
|
||||
struct SkillStatusMissingLite: Decodable {
|
||||
let bins: [String]
|
||||
let env: [String]
|
||||
let config: [String]
|
||||
let os: [String]
|
||||
}
|
||||
|
||||
struct CronStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let jobs: Int
|
||||
let nextwakeatms: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case jobs
|
||||
case nextwakeatms = "nextWakeAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
struct CronJobsListLite: Decodable {
|
||||
let jobs: [CronJob]
|
||||
let total: Int?
|
||||
}
|
||||
|
||||
struct DreamingStatusEnvelope: Decodable {
|
||||
let dreaming: DreamingStatusLite?
|
||||
}
|
||||
|
||||
struct DreamingStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let shortTermCount: Int?
|
||||
let totalSignalCount: Int?
|
||||
let promotedToday: Int?
|
||||
let storeError: String?
|
||||
let shortTermEntries: [DreamingEntryLite]?
|
||||
let signalEntries: [DreamingEntryLite]?
|
||||
let promotedEntries: [DreamingEntryLite]?
|
||||
let phases: [String: DreamingPhaseStatusLite]?
|
||||
|
||||
var nextRunAtMs: Int? {
|
||||
self.phases?.values
|
||||
.compactMap(\.nextRunAtMs)
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamingEntryLite: Decodable, Identifiable {
|
||||
let key: String
|
||||
let path: String
|
||||
let startLine: Int
|
||||
let endLine: Int
|
||||
let snippet: String
|
||||
let recallCount: Int
|
||||
let dailyCount: Int
|
||||
let groundedCount: Int
|
||||
let totalSignalCount: Int
|
||||
let lightHits: Int
|
||||
let remHits: Int
|
||||
let phaseHitCount: Int
|
||||
let promotedAt: String?
|
||||
let lastRecalledAt: String?
|
||||
|
||||
var id: String {
|
||||
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamDiaryLite: Decodable {
|
||||
let agentId: String
|
||||
let found: Bool
|
||||
let path: String
|
||||
let content: String?
|
||||
let updatedAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseStatusLite: Decodable {
|
||||
let enabled: Bool?
|
||||
let cron: String?
|
||||
let managedCronPresent: Bool?
|
||||
let nextRunAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let status: DreamingPhaseStatusLite
|
||||
}
|
||||
|
||||
struct ConfigSnapshotLite: Decodable {
|
||||
let hash: String?
|
||||
let config: ConfigRootLite?
|
||||
|
||||
func agentConfig(id: String) -> AgentConfigLite? {
|
||||
self.config?.agents?.list?.first { $0.id == id }
|
||||
}
|
||||
|
||||
func effectiveSkillFilter(agentId: String) -> [String]? {
|
||||
if let agentSkills = self.agentConfig(id: agentId)?.skills {
|
||||
return agentSkills
|
||||
}
|
||||
return self.config?.agents?.defaults?.skills
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigRootLite: Decodable {
|
||||
let agents: AgentsConfigLite?
|
||||
}
|
||||
|
||||
struct AgentsConfigLite: Decodable {
|
||||
let defaults: AgentDefaultsConfigLite?
|
||||
let list: [AgentConfigLite]?
|
||||
}
|
||||
|
||||
struct AgentDefaultsConfigLite: Decodable {
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct AgentConfigLite: Decodable {
|
||||
let id: String
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
case missingConfigHash
|
||||
case invalidPatchPayload
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingConfigHash:
|
||||
"Config hash missing; refresh and retry."
|
||||
case .invalidPatchPayload:
|
||||
"Could not encode the skill config update."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageSummaryLite: Decodable {
|
||||
let updatedAt: Int?
|
||||
let days: Int?
|
||||
let daily: [CostUsageDailyEntryLite]?
|
||||
let totals: [String: AnyCodable]?
|
||||
let cacheStatus: [String: AnyCodable]?
|
||||
|
||||
var totalCost: Double? {
|
||||
AgentProValueReader.doubleValue(self.totals?["totalCost"])
|
||||
}
|
||||
|
||||
var totalTokens: Int? {
|
||||
AgentProValueReader.intValue(self.totals?["totalTokens"])
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageDailyEntryLite: Decodable {
|
||||
let date: String
|
||||
let totalTokens: Int?
|
||||
let totalCost: Double?
|
||||
}
|
||||
348
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
348
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
let instancesValue: String
|
||||
let instancesDetail: String
|
||||
let instancesColor: Color
|
||||
let refresh: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Nodes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Nodes")
|
||||
.font(.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var totalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Presence")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)")
|
||||
self.detailMetric(label: "Agents", value: "\(self.agentCount)")
|
||||
self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Nodes")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not report any system presence entries."
|
||||
: "Connect a gateway to inspect connected nodes.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(nodes.enumerated()), id: \.element.presenceKey) { index, entry in
|
||||
NavigationLink {
|
||||
self.nodeDetail(entry)
|
||||
} label: {
|
||||
self.nodePresenceRow(entry, showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < nodes.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var sortedPresenceEntries: [PresenceEntry] {
|
||||
(self.overview?.presence ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.ts != rhs.ts { return lhs.ts > rhs.ts }
|
||||
return (Self.presenceLabel(lhs) ?? lhs.presenceKey)
|
||||
.localizedCaseInsensitiveCompare(Self.presenceLabel(rhs) ?? rhs.presenceKey) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func nodePresenceRow(_ entry: PresenceEntry, showsChevron: Bool = false) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let meta = Self.presenceMeta(entry) {
|
||||
Text(meta)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.presenceState(entry))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Self.presenceColor(entry))
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func nodeDetail(_ entry: PresenceEntry) -> some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: Self.presenceState(entry), color: Self.presenceColor(entry))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
ProCard {
|
||||
VStack(spacing: 0) {
|
||||
self.nodeDetailRow("Instance", value: entry.instanceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Device", value: entry.deviceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Host", value: entry.host)
|
||||
Divider()
|
||||
self.nodeDetailRow("IP", value: entry.ip)
|
||||
Divider()
|
||||
self.nodeDetailRow("Platform", value: entry.platform)
|
||||
Divider()
|
||||
self.nodeDetailRow("Version", value: entry.version)
|
||||
Divider()
|
||||
self.nodeDetailRow("Mode", value: entry.mode)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.nodeListCard(title: "Scopes", values: entry.scopes ?? [])
|
||||
self.nodeListCard(title: "Roles", values: entry.roles ?? [])
|
||||
self.nodeListCard(title: "Tags", values: entry.tags ?? [])
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func nodeDetailRow(_ title: String, value: String?) -> some View {
|
||||
let normalized = Self.normalized(value) ?? "n/a"
|
||||
return HStack(spacing: 10) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(normalized)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button {
|
||||
UIPasteboard.general.string = normalized
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(normalized == "n/a")
|
||||
.accessibilityLabel("Copy \(title)")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func nodeListCard(title: String, values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard {
|
||||
if values.isEmpty {
|
||||
Text("None reported.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(value)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func emptyRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private static func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
private static func presenceDetail(_ entry: PresenceEntry) -> String {
|
||||
let parts = [
|
||||
Self.normalized(entry.ip),
|
||||
Self.normalized(entry.platform),
|
||||
Self.normalized(entry.version),
|
||||
].compactMap(\.self)
|
||||
if !parts.isEmpty {
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
return Self.normalized(entry.text) ?? "Presence beacon received."
|
||||
}
|
||||
|
||||
private static func presenceMeta(_ entry: PresenceEntry) -> String? {
|
||||
let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ")
|
||||
let scopesCount = entry.scopes?.count ?? 0
|
||||
let rolesCount = entry.roles?.count ?? 0
|
||||
let labels = [
|
||||
Self.normalized(entry.instanceid).map { "instance \($0)" },
|
||||
tags.isEmpty ? nil : tags,
|
||||
scopesCount > 0 ? "\(scopesCount) scopes" : nil,
|
||||
rolesCount > 0 ? "\(rolesCount) roles" : nil,
|
||||
].compactMap(\.self)
|
||||
return labels.isEmpty ? nil : labels.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private static func presenceState(_ entry: PresenceEntry) -> String {
|
||||
if let reason = normalized(entry.reason) {
|
||||
return reason
|
||||
}
|
||||
if let mode = Self.normalized(entry.mode) {
|
||||
return mode
|
||||
}
|
||||
return Self.relativeTime(fromMilliseconds: entry.ts)
|
||||
}
|
||||
|
||||
private static func presenceIcon(_ entry: PresenceEntry) -> String {
|
||||
let family = Self.normalized(entry.devicefamily)?.lowercased()
|
||||
if family?.contains("phone") == true { return "iphone" }
|
||||
if family?.contains("tablet") == true || family?.contains("pad") == true { return "ipad" }
|
||||
if family?.contains("desktop") == true || family?.contains("mac") == true { return "desktopcomputer" }
|
||||
return "display"
|
||||
}
|
||||
|
||||
private static func presenceColor(_ entry: PresenceEntry) -> Color {
|
||||
self.normalized(entry.reason) == nil ? OpenClawBrand.accent : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
}
|
||||
|
||||
extension PresenceEntry {
|
||||
fileprivate var presenceKey: String {
|
||||
self.instanceid
|
||||
?? self.deviceid
|
||||
?? self.host
|
||||
?? self.ip
|
||||
?? "\(self.ts)"
|
||||
}
|
||||
}
|
||||
178
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
178
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
@@ -0,0 +1,178 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var cronStatusCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Scheduler")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(
|
||||
value: self.overview?.cronStatus?.enabled == true ? "on" : "off",
|
||||
color: self.cronColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
let jobCount = self.overview?.cronStatus?.jobs
|
||||
?? self.overview?.cronJobs.count
|
||||
?? 0
|
||||
self.detailMetric(label: "Jobs", value: "\(jobCount)")
|
||||
self.detailMetric(label: "Next", value: self.cronNextRunLabel)
|
||||
}
|
||||
if let cronActionStatusText {
|
||||
Text(cronActionStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var cronNextRunLabel: String {
|
||||
guard let nextWakeAtMs = self.overview?.cronStatus?.nextwakeatms else { return "none" }
|
||||
return Self.relativeTime(fromMilliseconds: nextWakeAtMs)
|
||||
}
|
||||
|
||||
func cronJobsList(limit: Int?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Jobs")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.sortedCronJobs
|
||||
let visible = limit.map { Array(jobs.prefix($0)) } ?? jobs
|
||||
if visible.isEmpty {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visible.enumerated()), id: \.element.id) { index, job in
|
||||
self.cronJobDetailRow(job)
|
||||
if index < visible.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var sortedCronJobs: [CronJob] {
|
||||
(self.overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobDetailRow(_ job: CronJob) -> some View {
|
||||
let busy = self.cronActionBusyIDs.contains(job.id)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(job.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Text(self.cronScheduleSummary(job))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.runCronJob(job) }
|
||||
} label: {
|
||||
Label("Run", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
|
||||
Button {
|
||||
Task { await self.setCronJob(job, enabled: !job.enabled) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if busy {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(self.cronJobState(job))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronJob(_ job: CronJob) async {
|
||||
await self.runCronAction(job, success: "Queued \(job.name).") {
|
||||
let params = CronRunParams(id: job.id, mode: "force")
|
||||
_ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setCronJob(_ job: CronJob, enabled: Bool) async {
|
||||
await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") {
|
||||
let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled))
|
||||
_ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronAction(
|
||||
_ job: CronJob,
|
||||
success: String,
|
||||
action: () async throws -> Void) async
|
||||
{
|
||||
guard self.gatewayConnected else { return }
|
||||
self.cronActionBusyIDs.insert(job.id)
|
||||
self.cronActionStatusText = nil
|
||||
defer { self.cronActionBusyIDs.remove(job.id) }
|
||||
do {
|
||||
try await action()
|
||||
self.cronActionStatusText = success
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.cronActionStatusText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
func cronScheduleSummary(_ job: CronJob) -> String {
|
||||
guard let schedule = job.schedule.value as? [String: AnyCodable] else { return "Schedule configured" }
|
||||
if let expr = Self.stringValue(schedule["expr"]) {
|
||||
return "Cron \(expr)"
|
||||
}
|
||||
if let everyMs = AgentProValueReader.intValue(schedule["everyMs"]) {
|
||||
return "Every \(Self.duration(milliseconds: everyMs))"
|
||||
}
|
||||
if let kind = Self.stringValue(schedule["kind"]) {
|
||||
return kind
|
||||
}
|
||||
return "Schedule configured"
|
||||
}
|
||||
}
|
||||
148
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
148
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .nodes:
|
||||
self.nodesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
self.usageDestination
|
||||
case .dreaming:
|
||||
self.dreamingDestination
|
||||
}
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.skillsPolicyControls
|
||||
self.skillsFilterField
|
||||
self.clawHubSearchCard
|
||||
self.skillsList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Skills")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var nodesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
instancesValue: self.instancesValue,
|
||||
instancesDetail: self.instancesDetail,
|
||||
instancesColor: self.instancesColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
var cronDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor)
|
||||
self.cronStatusCard
|
||||
self.cronJobsList(limit: nil)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var usageDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.usageTotalsCard
|
||||
self.usageDailyList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Usage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
dreamingValue: self.dreamingValue,
|
||||
dreamingDetail: self.dreamingDetail,
|
||||
dreamingColor: self.dreamingColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
35
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
35
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
251
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
251
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func agentName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
func agentBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
|
||||
let words = self.agentName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
return initials.isEmpty ? "OC" : initials.uppercased()
|
||||
}
|
||||
|
||||
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
|
||||
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
|
||||
return state.color.opacity(0.62)
|
||||
}
|
||||
|
||||
func agentDetail(for agent: AgentSummary) -> String {
|
||||
let parts = [
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
agent.id == self.appModel.gatewayDefaultAgentId ? "default" : nil,
|
||||
].compactMap(\.self)
|
||||
return parts.isEmpty ? agent.id : parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
func agentSessionSummary(_ agent: AgentSummary) -> String {
|
||||
guard self.gatewayConnected else { return "0" }
|
||||
if agent.id == self.activeAgentID {
|
||||
return self.appModel.isOperatorGatewayConnected ? "1 running" : "0"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func agentRuntimeSummary(_ agent: AgentSummary) -> String {
|
||||
if let runtime = agent.agentruntime,
|
||||
let id = runtime["id"]?.value as? String,
|
||||
let normalized = self.normalized(id)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
if let model = self.modelLabel(for: agent) {
|
||||
return Self.shortModelLabel(model)
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
||||
guard self.gatewayConnected else { return .idle }
|
||||
if agent.id == self.activeAgentID { return .online }
|
||||
if self.cronJobsContain(agentID: agent.id) { return .busy }
|
||||
return .idle
|
||||
}
|
||||
|
||||
func cronJobsContain(agentID: String) -> Bool {
|
||||
self.recentCronJobs.contains { job in
|
||||
self.normalized(job.agentid) == agentID && job.enabled
|
||||
}
|
||||
}
|
||||
|
||||
func modelLabel(for agent: AgentSummary) -> String? {
|
||||
guard let model = agent.model else { return nil }
|
||||
for key in ["primary", "name", "id", "model"] {
|
||||
if let value = model[key]?.value as? String,
|
||||
let normalized = self.normalized(value)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func shortModelLabel(_ model: String) -> String {
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "default" }
|
||||
let leaf = trimmed.split(separator: "/").last.map(String.init) ?? trimmed
|
||||
return leaf
|
||||
.replacingOccurrences(of: "claude-", with: "")
|
||||
.replacingOccurrences(of: "gpt-", with: "")
|
||||
}
|
||||
|
||||
func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
func cronJobDetail(_ job: CronJob) -> String {
|
||||
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
|
||||
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if let description = self.normalized(job.description) {
|
||||
return description
|
||||
}
|
||||
if let agentId = self.normalized(job.agentid) {
|
||||
return agentId
|
||||
}
|
||||
return job.id
|
||||
}
|
||||
|
||||
func cronJobState(_ job: CronJob) -> String {
|
||||
if !job.enabled {
|
||||
return "paused"
|
||||
}
|
||||
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
|
||||
return status
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshOverview(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
self.overview = nil
|
||||
self.overviewErrorText = nil
|
||||
self.overviewLoading = false
|
||||
return
|
||||
}
|
||||
if self.overviewLoading, force == false {
|
||||
return
|
||||
}
|
||||
|
||||
self.overviewLoading = true
|
||||
self.overviewErrorText = nil
|
||||
defer { self.overviewLoading = false }
|
||||
|
||||
let activeAgentID = self.activeAgentID
|
||||
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
|
||||
async let skills = self.requestOptional(
|
||||
SkillStatusReportLite.self,
|
||||
method: "skills.status",
|
||||
paramsJSON: skillsParams)
|
||||
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
|
||||
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
|
||||
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
|
||||
async let cronJobs = self.requestOptional(
|
||||
CronJobsListLite.self,
|
||||
method: "cron.list",
|
||||
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
|
||||
timeoutSeconds: 12)
|
||||
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
|
||||
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
|
||||
async let usage = self.requestOptional(
|
||||
CostUsageSummaryLite.self,
|
||||
method: "usage.cost",
|
||||
paramsJSON: "{\"days\":31}",
|
||||
timeoutSeconds: 12)
|
||||
|
||||
let loadedSkills = await skills
|
||||
let loadedConfig = await config
|
||||
let loadedPresence = await presence
|
||||
let loadedCronStatus = await cronStatus
|
||||
let loadedCronJobs = await cronJobs
|
||||
let loadedDreaming = await dreaming
|
||||
let loadedDreamDiary = await dreamDiary
|
||||
let loadedUsage = await usage
|
||||
let snapshot = AgentOverviewSnapshot(
|
||||
skills: loadedSkills,
|
||||
presence: loadedPresence ?? [],
|
||||
cronStatus: loadedCronStatus,
|
||||
cronJobs: loadedCronJobs?.jobs ?? [],
|
||||
dreaming: loadedDreaming?.dreaming,
|
||||
dreamDiary: loadedDreamDiary,
|
||||
usage: loadedUsage,
|
||||
activeAgentId: activeAgentID,
|
||||
agentSkillFilter: loadedSkills?.agentSkillFilter
|
||||
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
|
||||
loadedAt: Date())
|
||||
|
||||
if snapshot.hasAnyLiveData {
|
||||
self.overview = snapshot
|
||||
} else {
|
||||
self.overview = snapshot
|
||||
self.overviewErrorText = "Live overview could not load yet."
|
||||
}
|
||||
}
|
||||
|
||||
func requestOptional<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
method: String,
|
||||
paramsJSON: String = "{}",
|
||||
timeoutSeconds: Int = 8) async -> T?
|
||||
{
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func stringValue(_ value: AnyCodable?) -> String? {
|
||||
guard let string = value?.value as? String else { return nil }
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
static func currency(_ value: Double) -> String {
|
||||
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
|
||||
}
|
||||
|
||||
static func duration(milliseconds: Int) -> String {
|
||||
let seconds = max(0, milliseconds / 1000)
|
||||
if seconds < 60 { return "\(seconds)s" }
|
||||
let minutes = seconds / 60
|
||||
if minutes < 60 { return "\(minutes)m" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
|
||||
static func agentScopedParams(agentId: String) -> String {
|
||||
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "{}"
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
724
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
724
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
@@ -0,0 +1,724 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Agents")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("\(self.sortedAgents.count) total")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
action: {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentSearchPresented.toggle()
|
||||
}
|
||||
})
|
||||
self.headerIconButton(
|
||||
systemName: "arrow.clockwise",
|
||||
label: self.overviewLoading ? "Refreshing agents" : "Refresh agents",
|
||||
action: {
|
||||
self.overviewRefreshNonce += 1
|
||||
})
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if self.agentSearchPresented {
|
||||
TextField("Search agents", text: self.$agentSearchText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 38)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.searchFieldFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.searchFieldStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AgentRosterFilter.allCases) { filter in
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentRosterFilter = filter
|
||||
}
|
||||
} label: {
|
||||
Text(filter.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.agentRosterFilter == filter ? .primary : .secondary)
|
||||
.padding(.horizontal, 15)
|
||||
.frame(height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.agentRosterFilter == filter
|
||||
? Color.primary.opacity(0.13)
|
||||
: Color.primary.opacity(0.055))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(self.agentRosterFilter == filter ? 0.22 : 0.06))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if self.agentFiltersActive {
|
||||
self.headerIconButton(
|
||||
systemName: "xmark",
|
||||
label: "Clear filters",
|
||||
action: {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
})
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var agentFiltersActive: Bool {
|
||||
self.agentRosterFilter != .all
|
||||
|| !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var agentsSection: some View {
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
if self.filteredAgents.isEmpty {
|
||||
self.emptyAgentsRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.filteredAgents.enumerated()), id: \.element.id) { index, agent in
|
||||
self.agentRow(agent)
|
||||
if index < self.filteredAgents.count - 1 {
|
||||
Divider().padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var operationsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Live Operations")
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
self.metricTile(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .skills)
|
||||
self.metricTile(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Instances",
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .nodes)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor,
|
||||
route: .cron)
|
||||
self.metricTile(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .usage)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
if let overviewErrorText {
|
||||
Text(overviewErrorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dreamingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dreaming")
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
NavigationLink(value: AgentRoute.dreaming) {
|
||||
self.agentMenuRow(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
detail: self.dreamingDetail,
|
||||
value: self.dreamingValue,
|
||||
color: self.dreamingColor,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var cronSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Scheduled Work")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.recentCronJobs
|
||||
if jobs.isEmpty {
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(jobs.enumerated()), id: \.element.id) { index, job in
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.cronJobRow(job)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < jobs.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyAgentsRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "person.2.slash", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.emptyAgentsTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.emptyAgentsDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func agentRow(_ agent: AgentSummary) -> some View {
|
||||
let isActive = agent.id == self.activeAgentID
|
||||
let state = self.agentRosterState(for: agent)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
self.agentAvatar(agent, state: state)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.agentName(for: agent))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(state.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(state.color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Text(self.agentDetail(for: agent))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.agentMetric(label: "Sessions", value: self.agentSessionSummary(agent))
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.padding(.horizontal, 12)
|
||||
self.agentMetric(label: "Runtime", value: self.agentRuntimeSummary(agent))
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Button {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
} label: {
|
||||
Image(systemName: isActive ? "checkmark" : "arrow.right")
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isActive ? OpenClawBrand.accent : .primary)
|
||||
.frame(width: AgentLayout.actionButtonSize, height: AgentLayout.actionButtonSize)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isActive ? "Active agent" : "Make active agent")
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
}
|
||||
}
|
||||
|
||||
func headerIconButton(
|
||||
systemName: String,
|
||||
label: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
func agentAvatar(_ agent: AgentSummary, state: AgentRosterState) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Text(self.agentBadge(for: agent))
|
||||
.font(.system(size: self.agentBadge(for: agent).count > 2 ? 14 : 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.62)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.agentTint(for: agent, state: state),
|
||||
Color.primary.opacity(0.38),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 10, height: 10)
|
||||
.overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
func agentMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.74)
|
||||
}
|
||||
.frame(minWidth: 60, alignment: .leading)
|
||||
}
|
||||
|
||||
func agentMenuRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color,
|
||||
showsChevron: Bool = false) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
func metricTile(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
route: AgentRoute? = nil) -> some View
|
||||
{
|
||||
Group {
|
||||
if let route {
|
||||
NavigationLink(value: route) {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metricTileContent(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
showsChevron: Bool) -> some View
|
||||
{
|
||||
ProCard(padding: 12, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
Spacer()
|
||||
ProValuePill(value: value, color: color)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: AgentLayout.metricTileHeight, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyCronRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "clock.badge.questionmark", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.gatewayConnected ? "No scheduled jobs" : "Cron unavailable")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayConnected
|
||||
? "The gateway has no visible cron jobs."
|
||||
: "Connect a gateway to load scheduled work.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobRow(_ job: CronJob) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(job.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.cronJobState(job))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
var sortedAgents: [AgentSummary] {
|
||||
self.appModel.gatewayAgents.sorted { lhs, rhs in
|
||||
if lhs.id == self.activeAgentID { return true }
|
||||
if rhs.id == self.activeAgentID { return false }
|
||||
return self.agentName(for: lhs)
|
||||
.localizedCaseInsensitiveCompare(self.agentName(for: rhs)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
var filteredAgents: [AgentSummary] {
|
||||
let query = self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.sortedAgents.filter { agent in
|
||||
let matchesFilter: Bool = switch self.agentRosterFilter {
|
||||
case .all:
|
||||
true
|
||||
case .online:
|
||||
self.agentRosterState(for: agent) == .online
|
||||
case .busy:
|
||||
self.agentRosterState(for: agent) == .busy
|
||||
case .idle:
|
||||
self.agentRosterState(for: agent) == .idle
|
||||
}
|
||||
|
||||
guard matchesFilter else { return false }
|
||||
guard !query.isEmpty else { return true }
|
||||
let haystack = [
|
||||
self.agentName(for: agent),
|
||||
agent.id,
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
return haystack.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentID: String {
|
||||
self.normalized(self.appModel.selectedAgentId)
|
||||
?? self.normalized(self.appModel.gatewayDefaultAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var searchFieldFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var searchFieldStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
private var iconButtonFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var iconButtonStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
var emptyAgentsTitle: String {
|
||||
if !self.gatewayConnected { return "Agents unavailable" }
|
||||
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" }
|
||||
if self.agentRosterFilter != .all { return "No \(self.agentRosterFilter.title.lowercased()) agents" }
|
||||
return "No agents reported"
|
||||
}
|
||||
|
||||
var emptyAgentsDetail: String {
|
||||
if !self.gatewayConnected { return "Connect a gateway to load the live agent roster." }
|
||||
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Try another search or clear the agent filters."
|
||||
}
|
||||
if self.agentRosterFilter != .all { return "Clear the filter to view the full roster." }
|
||||
return "The connected gateway did not return an agent list."
|
||||
}
|
||||
|
||||
var overviewTaskID: String {
|
||||
[
|
||||
self.gatewayConnected ? "connected" : "offline",
|
||||
self.appModel.isOperatorGatewayConnected ? "operator" : "no-operator",
|
||||
self.activeAgentID,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
"\(self.overviewRefreshNonce)",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
var skillsValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let skills = self.overview?.skills else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(skills.enabledCount)/\(skills.totalCount)"
|
||||
}
|
||||
|
||||
var skillsDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load skills." }
|
||||
guard let skills = self.overview?.skills else {
|
||||
return self.overviewLoading ? "Loading skill status." : "Skill status is available from the gateway."
|
||||
}
|
||||
if skills.blockedCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.blockedCount) blocked"
|
||||
}
|
||||
if skills.missingRequirementCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.missingRequirementCount) need setup"
|
||||
}
|
||||
return "\(skills.enabledCount) enabled, \(skills.totalCount) installed"
|
||||
}
|
||||
|
||||
var instancesValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let count = self.overview?.presence.count else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
var instancesDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load instances." }
|
||||
guard let presence = self.overview?.presence else {
|
||||
return self.overviewLoading ? "Loading instance presence." : "Instance presence is available."
|
||||
}
|
||||
let labels = presence.prefix(2).compactMap(self.presenceLabel)
|
||||
if labels.isEmpty {
|
||||
return "No live instances reported."
|
||||
}
|
||||
return labels.joined(separator: ", ")
|
||||
}
|
||||
|
||||
var instancesColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return (self.overview?.presence.isEmpty == false) ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var cronValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let cronStatus = self.overview?.cronStatus else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return cronStatus.enabled ? "\(cronStatus.jobs)" : "off"
|
||||
}
|
||||
|
||||
var cronDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load cron." }
|
||||
guard let cronStatus = self.overview?.cronStatus else {
|
||||
return self.overviewLoading ? "Loading cron status." : "Cron status is available."
|
||||
}
|
||||
if let nextWakeAtMs = cronStatus.nextwakeatms {
|
||||
return "Next wake \(Self.relativeTime(fromMilliseconds: nextWakeAtMs))"
|
||||
}
|
||||
return cronStatus.enabled ? "Scheduler enabled" : "Scheduler disabled"
|
||||
}
|
||||
|
||||
var cronColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return self.overview?.cronStatus?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var usageValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let usage = self.overview?.usage else {
|
||||
return self.overviewLoading ? "..." : "7d"
|
||||
}
|
||||
if let cost = usage.totalCost {
|
||||
return Self.currency(cost)
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
return "7d"
|
||||
}
|
||||
|
||||
var usageDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load usage." }
|
||||
guard let usage = self.overview?.usage else {
|
||||
return self.overviewLoading ? "Loading recent usage." : "Recent usage is available."
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return "\(Self.compactNumber(tokens)) tokens in \(usage.days ?? 7)d"
|
||||
}
|
||||
return "No token usage reported for \(usage.days ?? 7)d."
|
||||
}
|
||||
|
||||
var dreamingValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let dreaming = self.overview?.dreaming else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return dreaming.enabled ? "on" : "off"
|
||||
}
|
||||
|
||||
var dreamingDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load dreaming." }
|
||||
guard let dreaming = self.overview?.dreaming else {
|
||||
return self.overviewLoading ? "Loading dreaming status." : "Background memory status is available."
|
||||
}
|
||||
if let nextRunAtMs = dreaming.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
return "\(dreaming.totalSignalCount ?? 0) signals, \(dreaming.promotedToday ?? 0) promoted today"
|
||||
}
|
||||
|
||||
var dreamingColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return self.overview?.dreaming?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var recentCronJobs: [CronJob] {
|
||||
(self.overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.updatedatms > rhs.updatedatms
|
||||
}
|
||||
}
|
||||
.prefix(4)
|
||||
.map(\.self)
|
||||
}
|
||||
}
|
||||
766
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
766
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
@@ -0,0 +1,766 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var skillsPolicyControls: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.activeAgentName)
|
||||
.font(.headline)
|
||||
Text(self.skillPolicySummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(
|
||||
value: self.agentSkillFilter == nil ? "all" : "\(self.agentSkillFilter?.count ?? 0)",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Enable All") {
|
||||
Task { await self.enableAllSkills() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button("Disable All", role: .destructive) {
|
||||
Task { await self.disableAllSkills() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button("Reset") {
|
||||
Task { await self.resetSkillPolicy() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy || self.agentSkillFilter == nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
if let skillMutationStatusText {
|
||||
Text(skillMutationStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
if let skillMutationErrorText {
|
||||
Text(skillMutationErrorText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var skillsFilterField: some View {
|
||||
ProCard(padding: 10, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search skills", text: self.$skillFilter)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
if !self.skillFilter.isEmpty {
|
||||
Button {
|
||||
self.skillFilter = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Picker("Status", selection: self.$skillStatusFilter) {
|
||||
ForEach(SkillStatusFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var clawHubSearchCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ProIconBadge(systemName: "square.and.arrow.down", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Install Skills")
|
||||
.font(.headline)
|
||||
Text("Search ClawHub and install into this workspace.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.clawHubLoading || !self.gatewayConnected)
|
||||
.accessibilityLabel("Search ClawHub")
|
||||
}
|
||||
|
||||
TextField("Search ClawHub", text: self.$clawHubQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
}
|
||||
|
||||
if self.clawHubLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let clawHubErrorText {
|
||||
Text(clawHubErrorText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
if !self.clawHubResults.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
let results = Array(self.clawHubResults.prefix(8))
|
||||
ForEach(Array(results.enumerated()), id: \.element.slug) { index, result in
|
||||
self.clawHubResultRow(result)
|
||||
if index < results.count - 1 {
|
||||
Divider().padding(.leading, 42)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View {
|
||||
let installing = self.clawHubInstallSlug == result.slug
|
||||
return HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(result.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(result.summary ?? result.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.installClawHubSkill(result) }
|
||||
} label: {
|
||||
Image(systemName: installing ? "hourglass" : "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(installing || !self.skillConfigBusyKeys.isEmpty)
|
||||
.accessibilityLabel("Install \(result.displayName)")
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
var skillsList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Installed Skills")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let skills = self.filteredSkills
|
||||
if skills.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "sparkles",
|
||||
title: self.gatewayConnected ? "No skills found" : "Skills unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "Try a different search or refresh from the gateway."
|
||||
: "Connect a gateway to load workspace skills.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(skills.enumerated()), id: \.element.name) { index, skill in
|
||||
self.skillRow(skill)
|
||||
if index < skills.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == self.activeAgentID }) {
|
||||
return self.agentName(for: agent)
|
||||
}
|
||||
return self.activeAgentID
|
||||
}
|
||||
|
||||
var agentSkillFilter: Set<String>? {
|
||||
self.overview?.agentSkillFilter.map { Set($0) }
|
||||
}
|
||||
|
||||
var skillPolicySummary: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to edit skills." }
|
||||
guard let filter = self.agentSkillFilter else {
|
||||
return "All available skills are allowed for this agent."
|
||||
}
|
||||
if filter.isEmpty {
|
||||
return "No skills are allowed for this agent."
|
||||
}
|
||||
return "\(filter.count) skills are allowed for this agent."
|
||||
}
|
||||
|
||||
var skillMutationBusy: Bool {
|
||||
!self.skillMutationBusyKeys.isEmpty
|
||||
}
|
||||
|
||||
var filteredSkills: [SkillStatusEntryLite] {
|
||||
let skills = self.overview?.skills?.skills ?? []
|
||||
let filter = self.skillFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return skills
|
||||
.filter { skill in
|
||||
self.matchesSkillStatusFilter(skill)
|
||||
}
|
||||
.filter { skill in
|
||||
guard !filter.isEmpty else { return true }
|
||||
return [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.source,
|
||||
].compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.contains(filter)
|
||||
}
|
||||
.sorted(by: self.sortSkills)
|
||||
}
|
||||
|
||||
func matchesSkillStatusFilter(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
switch self.skillStatusFilter {
|
||||
case .all:
|
||||
true
|
||||
case .enabled:
|
||||
self.skillStatus(skill).text == "enabled"
|
||||
case .off:
|
||||
!self.isSkillAllowed(skill) || skill.blockedByAgentFilter == true
|
||||
case .setup:
|
||||
skill.hasMissingRequirements
|
||||
case .blocked:
|
||||
skill.blockedByAllowlist == true
|
||||
}
|
||||
}
|
||||
|
||||
func sortSkills(_ lhs: SkillStatusEntryLite, _ rhs: SkillStatusEntryLite) -> Bool {
|
||||
let lhsEnabled = self.isSkillAllowed(lhs)
|
||||
let rhsEnabled = self.isSkillAllowed(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
|
||||
func skillRow(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
let busy = self.skillMutationBusyKeys.contains(skill.name)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.isSkillAllowed(skill) ? "checkmark.circle" : "nosign", color: status.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(skill.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let install = skill.installSummary {
|
||||
Text("Setup: \(install)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
self.skillToggle(skill, title: status.text)
|
||||
HStack(spacing: 6) {
|
||||
if self.canInstallSkillRequirements(skill) {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
.accessibilityLabel("Set up \(skill.displayName)")
|
||||
}
|
||||
Button {
|
||||
self.openSkillEditor(skill)
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.accessibilityLabel("Edit \(skill.displayName)")
|
||||
}
|
||||
Text(busy ? "saving" : status.text)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(status.color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
func skillToggle(_ skill: SkillStatusEntryLite, title: String) -> some View {
|
||||
Toggle(
|
||||
title,
|
||||
isOn: Binding(
|
||||
get: { self.isSkillAllowed(skill) },
|
||||
set: { enabled in
|
||||
Task { await self.setSkillAllowed(skill, enabled: enabled) }
|
||||
}))
|
||||
.labelsHidden()
|
||||
.disabled(self.skillMutationBusy)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
func isSkillAllowed(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
guard let filter = self.agentSkillFilter else { return true }
|
||||
return filter.contains(skill.name)
|
||||
}
|
||||
|
||||
func isSkillConfigBusy(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
self.skillConfigBusyKeys.contains(skill.effectiveSkillKey)
|
||||
|| self.clawHubInstallSlug != nil
|
||||
}
|
||||
|
||||
func canInstallSkillRequirements(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
&& !skill.missingBins.isEmpty
|
||||
}
|
||||
|
||||
func skillByKey(_ key: String) -> SkillStatusEntryLite? {
|
||||
(self.overview?.skills?.skills ?? []).first { skill in
|
||||
skill.effectiveSkillKey == key || skill.name == key
|
||||
}
|
||||
}
|
||||
|
||||
func openSkillEditor(_ skill: SkillStatusEntryLite) {
|
||||
self.skillEditorSelection = SkillEditorSelection(id: skill.effectiveSkillKey)
|
||||
}
|
||||
|
||||
func skillAPIKeyBinding(for skill: SkillStatusEntryLite) -> Binding<String> {
|
||||
Binding(
|
||||
get: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? "" },
|
||||
set: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] = $0 })
|
||||
}
|
||||
|
||||
var missingSkillEditorSheet: some View {
|
||||
NavigationStack {
|
||||
ContentUnavailableView("Skill unavailable", systemImage: "sparkles")
|
||||
.navigationTitle("Skill")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
self.skillEditorSelection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorSheet(_ skill: SkillStatusEntryLite) -> some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.skillEditorHeader(skill)
|
||||
self.skillEditorControls(skill)
|
||||
self.skillEditorSetup(skill)
|
||||
self.skillEditorMetadata(skill)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle(skill.displayName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
self.skillEditorSelection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorHeader(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
return ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: skill.isGloballyEnabled ? "checkmark.circle" : "pause.circle",
|
||||
color: status.color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skill.displayName)
|
||||
.font(.headline)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: status.text, color: status.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(
|
||||
"Enabled globally",
|
||||
isOn: Binding(
|
||||
get: { skill.isGloballyEnabled },
|
||||
set: { enabled in
|
||||
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
|
||||
}))
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
|
||||
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API key")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
SecureField(primaryEnv, text: self.skillAPIKeyBinding(for: skill))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
Task { await self.saveSkillAPIKey(skill) }
|
||||
} label: {
|
||||
Label("Save key", systemImage: "key")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
if let homepage = skill.homepageURL {
|
||||
Link("Get key", destination: homepage)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let message = self.skillConfigMessages[skill.effectiveSkillKey] {
|
||||
Text(message.text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(message.kind == .success ? OpenClawBrand.accent : OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Setup")
|
||||
.font(.headline)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
} else {
|
||||
Text("No missing requirements reported.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let install = skill.install?.first {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Label(install.label, systemImage: "wrench.and.screwdriver")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill) || install.id == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorMetadata(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.detailMetric(label: "Key", value: skill.effectiveSkillKey)
|
||||
self.detailMetric(label: "Source", value: self.normalized(skill.source) ?? "unknown")
|
||||
if let filePath = self.normalized(skill.filePath) {
|
||||
Text(filePath)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setSkillAllowed(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
let base = self.agentSkillFilter ?? Set(allNames)
|
||||
var next = base
|
||||
if enabled {
|
||||
next.insert(skill.name)
|
||||
} else {
|
||||
next.remove(skill.name)
|
||||
}
|
||||
await self.patchAgentSkills(Array(next).sorted(), busyKey: skill.name)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func enableAllSkills() async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
await self.patchAgentSkills(allNames, busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func disableAllSkills() async {
|
||||
await self.patchAgentSkills([], busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resetSkillPolicy() async {
|
||||
await self.patchAgentSkills(nil, busyKey: "__all__")
|
||||
}
|
||||
|
||||
var allSkillNames: [String] {
|
||||
(self.overview?.skills?.skills ?? [])
|
||||
.map(\.name)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.skillMutationBusyKeys.insert(busyKey)
|
||||
self.skillMutationErrorText = nil
|
||||
self.skillMutationStatusText = nil
|
||||
defer { self.skillMutationBusyKeys.remove(busyKey) }
|
||||
|
||||
do {
|
||||
let config = try await self.requestConfigSnapshot()
|
||||
guard let baseHash = self.normalized(config.hash) else {
|
||||
throw SkillMutationError.missingConfigHash
|
||||
}
|
||||
if skills == nil,
|
||||
config.agentConfig(id: self.activeAgentID) == nil
|
||||
{
|
||||
self.skillMutationStatusText = "This agent already inherits the default skill policy."
|
||||
return
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
_ = try await self.appModel.operatorSession.request(
|
||||
method: "config.patch",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 20)
|
||||
self.skillMutationStatusText = skills == nil ? "Skill policy reset." : "Skill policy saved."
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.skillMutationErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateSkillGlobalEnabled(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, enabled: enabled)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
return enabled ? "Skill enabled." : "Skill disabled."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func saveSkillAPIKey(_ skill: SkillStatusEntryLite) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let apiKey = self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? ""
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, apiKey: apiKey)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
self.skillAPIKeyDrafts[skill.effectiveSkillKey] = ""
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "API key cleared."
|
||||
: "API key saved."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installSkillRequirements(_ skill: SkillStatusEntryLite) async {
|
||||
guard let installId = skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installId.isEmpty
|
||||
else { return }
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillInstallParams(name: skill.name, installId: installId, timeoutMs: 120_000)
|
||||
let data = try await self.requestGateway(
|
||||
method: "skills.install",
|
||||
params: params,
|
||||
timeoutSeconds: 125)
|
||||
return (try? JSONDecoder().decode(SkillInstallResultLite.self, from: data).message) ?? "Installed."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.clawHubInstallSlug = result.slug
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubInstallSlug = nil }
|
||||
do {
|
||||
let params = ClawHubInstallParams(slug: result.slug)
|
||||
_ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125)
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func searchClawHubSkills() async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.clawHubLoading = true
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubLoading = false }
|
||||
do {
|
||||
let query = self.clawHubQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let params = ClawHubSearchParams(query: query.isEmpty ? nil : query, limit: 20)
|
||||
let data = try await self.requestGateway(method: "skills.search", params: params, timeoutSeconds: 20)
|
||||
self.clawHubResults = try JSONDecoder().decode(ClawHubSearchResponseLite.self, from: data).results
|
||||
} catch {
|
||||
self.clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runSkillConfigMutation(
|
||||
_ skill: SkillStatusEntryLite,
|
||||
action: () async throws -> String) async
|
||||
{
|
||||
let key = skill.effectiveSkillKey
|
||||
self.skillConfigBusyKeys.insert(key)
|
||||
self.skillConfigMessages[key] = nil
|
||||
defer { self.skillConfigBusyKeys.remove(key) }
|
||||
|
||||
do {
|
||||
let message = try await action()
|
||||
self.skillConfigMessages[key] = SkillEditorMessage(kind: .success, text: message)
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.skillConfigMessages[key] = SkillEditorMessage(
|
||||
kind: .error,
|
||||
text: Self.skillMutationMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
func requestGateway(
|
||||
method: String,
|
||||
params: some Encodable,
|
||||
timeoutSeconds: Int) async throws -> Data
|
||||
{
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: "config.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 12)
|
||||
return try JSONDecoder().decode(ConfigSnapshotLite.self, from: data)
|
||||
}
|
||||
|
||||
static func agentSkillsPatchRaw(agentId: String, skills: [String]?) throws -> String {
|
||||
let skillValue: Any = skills ?? NSNull()
|
||||
let patch: [String: Any] = [
|
||||
"agents": [
|
||||
"list": [
|
||||
[
|
||||
"id": agentId,
|
||||
"skills": skillValue,
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: patch, options: [.sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
static func skillMutationMessage(_ error: Error) -> String {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("operator.admin") || lower.contains("unauthorized") {
|
||||
return "This gateway connection cannot edit config yet. Reconnect with admin scope."
|
||||
}
|
||||
return gatewayError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
func skillStatus(_ skill: SkillStatusEntryLite) -> (text: String, color: Color) {
|
||||
if !self.isSkillAllowed(skill) {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.blockedByAllowlist == true {
|
||||
return ("blocked", .secondary)
|
||||
}
|
||||
if skill.blockedByAgentFilter == true {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.disabled == true {
|
||||
return ("disabled", .secondary)
|
||||
}
|
||||
if skill.hasMissingRequirements {
|
||||
return ("setup", OpenClawBrand.warn)
|
||||
}
|
||||
return ("enabled", OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
81
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
81
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var usageTotalsCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Totals")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: "\(self.overview?.usage?.days ?? 31)d", color: OpenClawBrand.accent)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Cost", value: self.usageValue)
|
||||
self.detailMetric(label: "Tokens", value: self.usageTokenValue)
|
||||
self.detailMetric(label: "Cache", value: self.usageCacheValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var usageTokenValue: String {
|
||||
guard let tokens = self.overview?.usage?.totalTokens else { return "0" }
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
|
||||
var usageCacheValue: String {
|
||||
guard let cacheStatus = self.normalized(self.overview?.usage?.cacheStatus?["status"]?.value as? String) else {
|
||||
return "n/a"
|
||||
}
|
||||
return cacheStatus
|
||||
}
|
||||
|
||||
var usageDailyList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Daily")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let days = self.overview?.usage?.daily ?? []
|
||||
if days.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "chart.bar",
|
||||
title: "No daily usage yet",
|
||||
detail: "The gateway returned totals without daily session cost rows.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(days.prefix(14).enumerated()), id: \.element.date) { index, day in
|
||||
self.usageDayRow(day)
|
||||
if index < min(days.count, 14) - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func usageDayRow(_ day: CostUsageDailyEntryLite) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "calendar", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(day.date)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(Self.compactNumber(day.totalTokens ?? 0)) tokens")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.currency(day.totalCost ?? 0))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
163
apps/ios/Sources/Design/AgentProTab.swift
Normal file
163
apps/ios/Sources/Design/AgentProTab.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@State var overviewRefreshNonce: Int = 0
|
||||
@State var agentRosterFilter: AgentRosterFilter = .all
|
||||
@State var agentSearchPresented = false
|
||||
@State var agentSearchText = ""
|
||||
@State var skillFilter: String = ""
|
||||
@State var skillStatusFilter: SkillStatusFilter = .all
|
||||
@State var skillMutationBusyKeys: Set<String> = []
|
||||
@State var skillMutationErrorText: String?
|
||||
@State var skillMutationStatusText: String?
|
||||
@State var skillConfigBusyKeys: Set<String> = []
|
||||
@State var skillConfigMessages: [String: SkillEditorMessage] = [:]
|
||||
@State var skillAPIKeyDrafts: [String: String] = [:]
|
||||
@State var skillEditorSelection: SkillEditorSelection?
|
||||
@State var clawHubQuery: String = ""
|
||||
@State var clawHubResults: [ClawHubSearchResultLite] = []
|
||||
@State var clawHubLoading: Bool = false
|
||||
@State var clawHubErrorText: String?
|
||||
@State var clawHubInstallSlug: String?
|
||||
@State var cronActionBusyIDs: Set<String> = []
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case skills
|
||||
case nodes
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
}
|
||||
|
||||
enum SkillStatusFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case enabled
|
||||
case off
|
||||
case setup
|
||||
case blocked
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .enabled: "Enabled"
|
||||
case .off: "Off"
|
||||
case .setup: "Setup"
|
||||
case .blocked: "Blocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentRosterFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let filterHeight: CGFloat = 34
|
||||
static let rowMinHeight: CGFloat = 104
|
||||
static let metricTileHeight: CGFloat = 94
|
||||
static let actionButtonSize: CGFloat = 34
|
||||
}
|
||||
|
||||
enum AgentRosterState: Equatable {
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .busy: OpenClawBrand.warn
|
||||
case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillEditorSelection: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct SkillEditorMessage {
|
||||
let kind: Kind
|
||||
let text: String
|
||||
|
||||
enum Kind {
|
||||
case success
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
self.operationsSection
|
||||
self.dreamingSection
|
||||
self.cronSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: AgentRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
apps/ios/Sources/Design/ChatProTab.swift
Normal file
191
apps/ios/Sources/Design/ChatProTab.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(spacing: 0) {
|
||||
self.header
|
||||
if let viewModel {
|
||||
OpenClawChatView(
|
||||
viewModel: viewModel,
|
||||
drawsBackground: false,
|
||||
showsSessionSwitcher: false,
|
||||
userAccent: self.chatUserAccent,
|
||||
assistantName: self.agentDisplayName,
|
||||
assistantAvatarText: self.agentBadge,
|
||||
assistantAvatarTint: OpenClawBrand.accent,
|
||||
showsAssistantAvatars: false,
|
||||
composerChrome: .clean,
|
||||
messagePlaceholder: "Message \(self.agentDisplayName)...",
|
||||
talkControl: self.talkControl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Chat is preparing")
|
||||
.font(.headline)
|
||||
Text("The operator session will attach when the gateway is ready.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task {
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatSessionKey) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 11) {
|
||||
Text(self.agentBadge)
|
||||
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent,
|
||||
OpenClawBrand.accentHot,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(self.agentDisplayName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text("AI Assistant")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.connectionPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
guard let viewModel else {
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
diagnosticsLog: { message in
|
||||
GatewayDiagnostics.log(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
guard viewModel.sessionKey != sessionKey else { return }
|
||||
viewModel.switchSession(to: sessionKey)
|
||||
}
|
||||
|
||||
private var talkControl: OpenClawChatTalkControl {
|
||||
OpenClawChatTalkControl(
|
||||
isEnabled: self.appModel.talkMode.isEnabled,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isGatewayConnected: self.appModel.talkMode.isGatewayConnected,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
providerLabel: self.appModel.talkMode.gatewayTalkProviderLabel,
|
||||
toggle: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
self.appModel.setTalkEnabled(!self.appModel.talkMode.isEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
private var activeAgentID: String {
|
||||
self.normalized(self.appModel.selectedAgentId)
|
||||
?? self.normalized(self.appModel.gatewayDefaultAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
Text(self.gatewayConnected ? "Connected" : "Connecting")
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
self.appModel.gatewayAgents.first { $0.id == self.activeAgentID }
|
||||
}
|
||||
|
||||
private var agentDisplayName: String {
|
||||
self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName
|
||||
}
|
||||
|
||||
private var agentBadge: String {
|
||||
if let identity = self.activeAgent?.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.agentDisplayName
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
281
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
281
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommandPanel<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent = false
|
||||
var padding: CGFloat = 13
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
tint: Color? = nil,
|
||||
isProminent: Bool = false,
|
||||
padding: CGFloat = 13,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.tint = tint
|
||||
self.isProminent = isProminent
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ProCard(
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: 12)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.34),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var darkColors: [Color] {
|
||||
[
|
||||
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
|
||||
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
|
||||
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
|
||||
]
|
||||
}
|
||||
|
||||
private var lightColors: [Color] {
|
||||
[
|
||||
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
|
||||
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
|
||||
.white,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(self.item.color.opacity(0.12))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
Spacer(minLength: 6)
|
||||
Text(self.item.trailing)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(self.item.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 6)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 68)
|
||||
}
|
||||
Text(self.progressLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let progress = self.item.progress else {
|
||||
return self.item.state
|
||||
}
|
||||
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
|
||||
return self.item.state
|
||||
}
|
||||
return "\(Int((progress * 100).rounded()))%"
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandApprovalRow: View {
|
||||
let item: CommandCenterTab.ApprovalItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.item.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.item.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.item.priority)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.item.color.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandEmptyStateRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(OpenClawBrand.ok.opacity(0.10))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandTaskRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Text(self.item.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
|
||||
Text(self.item.detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(width: 64, alignment: .leading)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 56)
|
||||
}
|
||||
Text(self.item.state)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandLiveActivityRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProStatusDot(color: self.color)
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(self.value)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.08))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
692
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
692
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
@@ -0,0 +1,692 @@
|
||||
import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
enum WorkRoute {
|
||||
case chat(String?)
|
||||
case settings
|
||||
}
|
||||
|
||||
struct WorkItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
let progress: Double?
|
||||
let route: WorkRoute
|
||||
}
|
||||
|
||||
struct ApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let priority: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.pendingApprovals
|
||||
self.activeTasks
|
||||
self.liveActivity
|
||||
self.startWorkAction
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task(id: self.activeSessionsRefreshID) {
|
||||
await self.refreshActiveSessionsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
Text("OpenClaw")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var commandAmbientOverlay: some View {
|
||||
Group {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.05),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayCard: some View {
|
||||
CommandPanel(isProminent: true, padding: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
icon: "network",
|
||||
title: "Connection",
|
||||
value: self.gatewayConnected ? "Online" : "Offline",
|
||||
color: self.gatewayStatusColor)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "server.rack",
|
||||
title: "Address",
|
||||
value: self.gatewayAddressText,
|
||||
color: OpenClawBrand.accent)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayAgentCountText,
|
||||
color: OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.colorScheme == .dark ? Color.black.opacity(0.16) : Color.black.opacity(0.026))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func gatewayFact(icon: String, title: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(color)
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(title == "Connection" ? color : .primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private var pendingApprovals: some View {
|
||||
self.pendingApprovalsContent
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var pendingApprovalsContent: some View {
|
||||
CommandPanel(
|
||||
tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn,
|
||||
isProminent: self.pendingApproval != nil,
|
||||
padding: self.pendingApproval == nil ? 11 : 13)
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Pending approvals",
|
||||
value: self.pendingApproval == nil ? nil : "Review requests ›",
|
||||
color: OpenClawBrand.accentHot,
|
||||
badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)")
|
||||
|
||||
if self.approvalItems.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "No approvals waiting",
|
||||
detail: self
|
||||
.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
|
||||
CommandApprovalRow(item: item)
|
||||
if index < self.approvalItems.count - 1 {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.approvalRowsFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let pendingApproval {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||||
} label: {
|
||||
Label("Allow", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
if pendingApproval.allowsAllowAlways {
|
||||
Button {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
} label: {
|
||||
Label("Always", systemImage: "checkmark.shield")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||||
} label: {
|
||||
Label("Deny", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeTasks: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Active sessions",
|
||||
value: self.activeSessionsSummaryText,
|
||||
color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.visibleActiveSessionRows) { item in
|
||||
Button {
|
||||
self.open(item.route)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var liveActivity: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Live activity",
|
||||
value: nil,
|
||||
color: OpenClawBrand.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
CommandLiveActivityRow(
|
||||
title: self.liveActivityTitle,
|
||||
value: self.liveActivityValue,
|
||||
color: self.liveActivityColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var startWorkAction: some View {
|
||||
CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) {
|
||||
Button(action: self.openChat) {
|
||||
Label("Start work", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.fill(LinearGradient(
|
||||
colors: [OpenClawBrand.accentHot, OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
icon: String? = nil,
|
||||
badgeValue: String? = nil,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(OpenClawBrand.accentHot, in: Capsule())
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
Text(value)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Healthy" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = status.lowercased()
|
||||
if lowercased.contains("approval") { return "Approval" }
|
||||
if lowercased.contains("reconnect") { return "Reconnecting" }
|
||||
if lowercased.contains("connect") { return "Connecting" }
|
||||
if lowercased.contains("idle") { return "Idle" }
|
||||
return "Offline"
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
private var gatewayAddressText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "Unknown"
|
||||
}
|
||||
|
||||
private var gatewayAgentCountText: String {
|
||||
guard self.gatewayConnected else { return "—" }
|
||||
return "\(self.appModel.gatewayAgents.count)"
|
||||
}
|
||||
|
||||
private var activeSessionsSummaryText: String {
|
||||
let count = self.activeSessionRows.count
|
||||
if count == 0 {
|
||||
return self.gatewayConnected ? "No sessions" : "Offline"
|
||||
}
|
||||
if self.sessionWorkItems.isEmpty {
|
||||
return self.gatewayConnected ? "\(count) ready" : "Offline"
|
||||
}
|
||||
return "\(count) \(count == 1 ? "session" : "sessions")"
|
||||
}
|
||||
|
||||
private var approvalItems: [ApprovalItem] {
|
||||
if let pendingApproval {
|
||||
return [
|
||||
ApprovalItem(
|
||||
id: "pending-real",
|
||||
icon: "terminal.fill",
|
||||
title: pendingApproval.commandPreview ?? "Review gateway action",
|
||||
detail: "Agent: \(self.appModel.activeAgentName)",
|
||||
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
|
||||
color: OpenClawBrand.danger),
|
||||
ApprovalItem(
|
||||
id: "pending-context",
|
||||
icon: "doc.text.fill",
|
||||
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
|
||||
detail: "Gateway request",
|
||||
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
|
||||
color: OpenClawBrand.warn),
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private var approvalRowsFill: Color {
|
||||
self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022)
|
||||
}
|
||||
|
||||
private var activeSessionRows: [WorkItem] {
|
||||
self.sessionItems
|
||||
}
|
||||
|
||||
private var visibleActiveSessionRows: [WorkItem] {
|
||||
Array(self.activeSessionRows.prefix(3))
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) {
|
||||
return "\(Self.sessionTitle(session)) updated"
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "Approval waiting"
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText
|
||||
}
|
||||
|
||||
private var liveActivityValue: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }),
|
||||
let updatedAt = session.updatedAt,
|
||||
updatedAt > 0
|
||||
{
|
||||
return Self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "review"
|
||||
}
|
||||
return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue
|
||||
}
|
||||
|
||||
private var liveActivityColor: Color {
|
||||
if self.pendingApproval != nil { return OpenClawBrand.warn }
|
||||
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
private var gatewayDisplayStatusValue: String {
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? self.gatewayStateText : status
|
||||
}
|
||||
|
||||
private var activeSessionsRefreshID: String {
|
||||
[
|
||||
self.appModel.isOperatorGatewayConnected ? "connected" : "offline",
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
let liveItems = self.sessionWorkItems
|
||||
if !liveItems.isEmpty { return liveItems }
|
||||
return self.defaultSessionItems
|
||||
}
|
||||
|
||||
private var sessionWorkItems: [WorkItem] {
|
||||
let currentSessionKey = self.appModel.chatSessionKey
|
||||
return self.activeChatSessions
|
||||
.filter { !Self.isHiddenInternalSession($0.key) }
|
||||
.prefix(4)
|
||||
.map { session in
|
||||
let isCurrent = session.key == currentSessionKey
|
||||
return WorkItem(
|
||||
id: "chat-session-\(session.key)",
|
||||
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: Self.sessionTitle(session),
|
||||
detail: Self.sessionDetail(session),
|
||||
state: isCurrent ? "current" : "recent",
|
||||
trailing: "chat",
|
||||
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(session.key))
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultSessionItems: [WorkItem] {
|
||||
[
|
||||
WorkItem(
|
||||
id: "main-chat",
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Main chat",
|
||||
detail: self.appModel.activeAgentName,
|
||||
state: self.gatewayConnected ? "ready" : "offline",
|
||||
trailing: "session",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .chat(self.appModel.chatSessionKey)),
|
||||
WorkItem(
|
||||
id: "talk-mode",
|
||||
icon: "waveform",
|
||||
title: "Talk",
|
||||
detail: self.appModel.talkMode.statusText,
|
||||
state: self.appModel.talkMode.isEnabled ? "active" : "off",
|
||||
trailing: "voice",
|
||||
color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "device-capture",
|
||||
icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display",
|
||||
title: "Device capture",
|
||||
detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools",
|
||||
state: self.appModel.screenRecordActive ? "running" : "idle",
|
||||
trailing: "device",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "agent-roster",
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable",
|
||||
state: self.gatewayConnected ? "online" : "offline",
|
||||
trailing: "gateway",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
]
|
||||
}
|
||||
|
||||
private func open(_ route: WorkRoute) {
|
||||
switch route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshActiveSessionsIfNeeded() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
if !self.activeChatSessions.isEmpty {
|
||||
self.activeChatSessions = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: 12)
|
||||
self.activeChatSessions = Self.sessionChoices(
|
||||
response.sessions,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
} catch {
|
||||
self.activeChatSessions = []
|
||||
}
|
||||
}
|
||||
|
||||
private static func sessionChoices(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
currentSessionKey: String) -> [OpenClawChatSessionEntry]
|
||||
{
|
||||
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
||||
if let current = sorted.first(where: { $0.key == currentSessionKey }) {
|
||||
result.append(current)
|
||||
included.insert(current.key)
|
||||
}
|
||||
|
||||
for session in sorted {
|
||||
guard !included.contains(session.key) else { continue }
|
||||
guard !Self.isHiddenInternalSession(session.key) else { continue }
|
||||
result.append(session)
|
||||
included.insert(session.key)
|
||||
if result.count >= 4 { break }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let title = redactedSessionTitle(for: session.key) {
|
||||
return title
|
||||
}
|
||||
|
||||
let displayName = session.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let displayName, !displayName.isEmpty {
|
||||
return Self.redactedSessionTitle(for: displayName) ?? displayName
|
||||
}
|
||||
let subject = session.subject?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let subject, !subject.isEmpty {
|
||||
return Self.redactedSessionTitle(for: subject) ?? subject
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func redactedSessionTitle(for key: String) -> String? {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = trimmed.lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if lowercased.contains(":ios-") {
|
||||
return "iOS chat"
|
||||
}
|
||||
if lowercased.hasPrefix("telegram:") {
|
||||
return "Telegram chat"
|
||||
}
|
||||
if lowercased.hasPrefix("user:+") {
|
||||
return "Direct chat"
|
||||
}
|
||||
if lowercased.hasPrefix("cron:") {
|
||||
return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func humanizedSessionKey(_ key: String) -> String? {
|
||||
let words = key
|
||||
.replacingOccurrences(of: "_", with: "-")
|
||||
.split(separator: "-")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
guard !words.isEmpty else { return nil }
|
||||
|
||||
return words
|
||||
.map { word in
|
||||
switch word.lowercased() {
|
||||
case "ai", "api", "ios", "qmd", "url":
|
||||
word.uppercased()
|
||||
default:
|
||||
word.prefix(1).uppercased() + String(word.dropFirst())
|
||||
}
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
return self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
|
||||
let date = Date(timeIntervalSince1970: milliseconds / 1000)
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .numeric
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: .now)
|
||||
}
|
||||
|
||||
private static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
||||
}
|
||||
|
||||
private var gatewaySubtitle: String {
|
||||
if let server = normalized(appModel.gatewayServerName) {
|
||||
return "\(self.appModel.activeAgentName) on \(server)"
|
||||
}
|
||||
if let address = normalized(appModel.gatewayRemoteAddress) {
|
||||
return "\(self.appModel.activeAgentName) via \(address)"
|
||||
}
|
||||
return self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
||||
self.appModel.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
Self.normalized(value)
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
static let storageKey = "appearance.preference"
|
||||
|
||||
static var launchArgumentPreference: AppAppearancePreference? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-appearance") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
return AppAppearancePreference(rawValue: arguments[valueIndex].lowercased())
|
||||
}
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System"
|
||||
case .light: "Light"
|
||||
case .dark: "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: nil
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .system: .unspecified
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
|
||||
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
|
||||
static let lightCanvasBottom = Color.white
|
||||
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
|
||||
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
|
||||
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
|
||||
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
|
||||
})
|
||||
static let accentHot = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
|
||||
: UIColor(red: 204 / 255.0, green: 75 / 255.0, blue: 69 / 255.0, alpha: 1)
|
||||
})
|
||||
static let danger = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 252 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1)
|
||||
: UIColor(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0, alpha: 1)
|
||||
})
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
static let graphite = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
|
||||
: UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
|
||||
})
|
||||
static let graphiteElevated = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
|
||||
: UIColor.white
|
||||
})
|
||||
static let graphiteSoft = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
|
||||
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
|
||||
})
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphite,
|
||||
graphiteElevated.opacity(0.96),
|
||||
Color(uiColor: .systemBackground),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static var toolbarChrome: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphiteElevated.opacity(0.92),
|
||||
graphite.opacity(0.78),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static func glassFill(brighten: Bool) -> Color {
|
||||
Color.black.opacity(brighten ? 0.10 : 0.22)
|
||||
}
|
||||
|
||||
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
|
||||
if active {
|
||||
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
|
||||
}
|
||||
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
|
||||
}
|
||||
|
||||
static func formSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
|
||||
colorScheme == .dark
|
||||
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
|
||||
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func openClawSheetChrome() -> some View {
|
||||
self
|
||||
.tint(OpenClawBrand.accent)
|
||||
.background {
|
||||
OpenClawBrand.sheetBackground
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
577
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
577
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
@@ -0,0 +1,577 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 20
|
||||
static let cardRadius: CGFloat = 14
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 22
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 260)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSectionHeader: View {
|
||||
let title: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
var uppercase = true
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(self.uppercase ? .uppercase : nil)
|
||||
Spacer()
|
||||
if let actionTitle {
|
||||
if let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
} else {
|
||||
Text(actionTitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 14
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.padding(self.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proPanelSurface(
|
||||
tint: self.tint,
|
||||
radius: self.radius,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let radius: CGFloat
|
||||
let tint: Color?
|
||||
let isProminent: Bool
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
ProPanelTexture()
|
||||
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
|
||||
.clipShape(shape)
|
||||
}
|
||||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
.overlay {
|
||||
shape
|
||||
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
|
||||
.padding(1)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
shape
|
||||
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
|
||||
.mask(alignment: .top) {
|
||||
Rectangle().frame(height: 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
let base = self.isProminent
|
||||
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
|
||||
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
|
||||
return AnyShapeStyle(base)
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.98),
|
||||
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
|
||||
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.72),
|
||||
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
|
||||
Color.black.opacity(0.08),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelTexture: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
|
||||
for y in stride(from: 2.0, through: size.height, by: 6.5) {
|
||||
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
|
||||
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
|
||||
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
|
||||
context.fill(Path(ellipseIn: dot), with: .color(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProLightGlassModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let radius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *), self.colorScheme == .light {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProGlassSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let fill: Color
|
||||
let stroke: Color
|
||||
let radius: CGFloat
|
||||
let isProminent: Bool
|
||||
var interactive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
let surfaced = content.background {
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
shape.strokeBorder(self.stroke, lineWidth: self.isProminent ? 1.2 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 26.0, *), self.colorScheme == .light {
|
||||
surfaced.glassEffect(
|
||||
self.interactive ? .regular.interactive() : .regular,
|
||||
in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
surfaced
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func proPanelSurface(
|
||||
tint: Color? = nil,
|
||||
radius: CGFloat = OpenClawProMetric.cardRadius,
|
||||
isProminent: Bool = false) -> some View
|
||||
{
|
||||
self.modifier(ProPanelSurfaceModifier(
|
||||
tint: tint,
|
||||
radius: radius,
|
||||
isProminent: isProminent))
|
||||
}
|
||||
|
||||
func proGlassSurface(
|
||||
fill: Color,
|
||||
stroke: Color,
|
||||
radius: CGFloat,
|
||||
isProminent: Bool = false,
|
||||
interactive: Bool = false) -> some View
|
||||
{
|
||||
self.modifier(ProGlassSurfaceModifier(
|
||||
fill: fill,
|
||||
stroke: stroke,
|
||||
radius: radius,
|
||||
isProminent: isProminent,
|
||||
interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let tint: Color?
|
||||
let radius: CGFloat
|
||||
let isProminent: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
ProPanelBackground(
|
||||
radius: self.radius,
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
|
||||
radius: self.isProminent ? 20 : 12,
|
||||
y: self.isProminent ? 10 : 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProIconBadge: View {
|
||||
let systemName: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 34, height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: self.color.opacity(0.35), radius: 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProValuePill: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(self.value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.color.opacity(self.colorScheme == .dark ? 0.12 : 0.08))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawProMark: View {
|
||||
var size: CGFloat = 42
|
||||
var shadowRadius: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
Image("OpenClawIcon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
|
||||
struct ProProgressBar: View {
|
||||
let progress: Double
|
||||
var color: Color = OpenClawBrand.accentHot
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let clamped = max(0, min(self.progress, 1))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.primary.opacity(0.10))
|
||||
Capsule()
|
||||
.fill(self.color)
|
||||
.frame(width: proxy.size.width * clamped)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProWorkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
var progress: Double?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Text(self.trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if let progress {
|
||||
ProProgressBar(progress: progress, color: self.color)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Text(self.state)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCapsule: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let color: Color
|
||||
var icon: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.color.opacity(self.colorScheme == .dark ? 0.16 : 0.10))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(self.color.opacity(self.colorScheme == .dark ? 0.30 : 0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
|
||||
Button {
|
||||
self.selection = index
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.trackFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func segmentFill(isSelected: Bool) -> Color {
|
||||
guard isSelected else { return .clear }
|
||||
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
|
||||
}
|
||||
|
||||
private var trackStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProHeroActionButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
|
||||
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
|
||||
radius: 18,
|
||||
isProminent: true,
|
||||
interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Image(systemName: self.icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10), in: Circle())
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.value)
|
||||
.font(.headline.weight(.bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Text(self.title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.04) : Color.white.opacity(0.52),
|
||||
stroke: self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
|
||||
radius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.value, color: self.color)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProTimelineRow: View {
|
||||
let done: Bool
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(
|
||||
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
|
||||
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
167
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
167
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
struct SettingsProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(VoiceWakeManager.self) var voiceWake
|
||||
@Environment(GatewayConnectionController.self) var gatewayController
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@AppStorage(AppAppearancePreference.storageKey) var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@AppStorage("node.displayName") var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") var instanceId: String = UUID().uuidString
|
||||
@AppStorage("camera.enabled") var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("screen.preventSleep") var preventSleep: Bool = true
|
||||
@AppStorage("talk.enabled") var talkEnabled: Bool = false
|
||||
@AppStorage(TalkModeProviderSelection.storageKey) var talkProviderSelectionRaw: String =
|
||||
TalkModeProviderSelection.gatewayDefault.rawValue
|
||||
@AppStorage(TalkModeRealtimeVoiceSelection.storageKey) var talkRealtimeVoiceSelectionRaw: String = ""
|
||||
@AppStorage(TalkSpeechLocale.storageKey) var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage("talk.button.enabled") var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("gateway.autoconnect") var gatewayAutoConnect: Bool = false
|
||||
@AppStorage("gateway.manual.enabled") var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("gateway.setupCode") var setupCode: String = ""
|
||||
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
|
||||
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
|
||||
@State var isReconnectingGateway = false
|
||||
@State var isRefreshingGateway = false
|
||||
@State var isChangingLocationMode = false
|
||||
@State var connectingGatewayID: String?
|
||||
@State var selectedAgentPickerId = ""
|
||||
@State var gatewayToken = ""
|
||||
@State var gatewayPassword = ""
|
||||
@State var manualGatewayPortText = ""
|
||||
@State var setupStatusText: String?
|
||||
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State var defaultShareInstruction = ""
|
||||
@State var showGatewayProblemDetails = false
|
||||
@State var showQRScanner = false
|
||||
@State var scannerError: String?
|
||||
@State var showResetOnboardingAlert = false
|
||||
@State var suppressCredentialPersist = false
|
||||
@State var locationStatusText: String?
|
||||
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State var notificationStatusText = "Checking"
|
||||
@State var notificationActionText = "Request Access"
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.settingsHeader
|
||||
self.appearanceSection
|
||||
self.gatewaySection
|
||||
self.settingsListSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
self.handleLocationModeChange(newValue)
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.persistGatewayToken(newValue)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.persistGatewayPassword(newValue)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
646
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
646
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
@@ -0,0 +1,646 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
extension SettingsProTab {
|
||||
func detailStatusCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var diagnosticChecksCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.diagnosticCheckRow(
|
||||
icon: "stethoscope",
|
||||
title: "Last Run",
|
||||
detail: self.diagnosticsLastRunText,
|
||||
value: self.diagnosticsRunValue,
|
||||
color: self.diagnosticsRunColor)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway Link",
|
||||
detail: self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "dot.radiowaves.left.and.right",
|
||||
title: "Discovery",
|
||||
detail: self.gatewayController.discoveryStatusText,
|
||||
value: "\(self.gatewayController.gateways.count)",
|
||||
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "waveform",
|
||||
title: "Talk Config",
|
||||
detail: self.appModel.talkMode.gatewayTalkTransportLabel,
|
||||
value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing",
|
||||
color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approval and event alert channel",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "rectangle.on.rectangle",
|
||||
title: "Screen Capture",
|
||||
detail: "Live foreground capture state",
|
||||
value: self.appModel.screenRecordActive ? "live" : "idle",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "mic",
|
||||
title: "Voice Wake",
|
||||
detail: self.appModel.voiceWake.statusText,
|
||||
value: self.voiceWakeEnabled ? "on" : "off",
|
||||
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func diagnosticCheckRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
func detailListCard(@ViewBuilder content: () -> some View) -> some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0, content: content)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func detailRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 42)
|
||||
}
|
||||
|
||||
func reconnectGateway() async {
|
||||
guard !self.isReconnectingGateway else { return }
|
||||
self.isReconnectingGateway = true
|
||||
defer { self.isReconnectingGateway = false }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
func refreshGateway() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count,
|
||||
talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
notificationStatusText: self.notificationStatusText)
|
||||
self.diagnosticsIssueCount = issueCount
|
||||
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
||||
}
|
||||
|
||||
func syncSettingsState() {
|
||||
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
defer { self.connectingGatewayID = nil }
|
||||
self.manualGatewayEnabled = false
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
|
||||
self.setupStatusText = err
|
||||
}
|
||||
}
|
||||
|
||||
func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func applySetupCode() -> Bool {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return false
|
||||
}
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return false
|
||||
}
|
||||
self.applyGatewayLink(link)
|
||||
return true
|
||||
}
|
||||
|
||||
func applyGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualGatewayHost = link.host
|
||||
self.manualGatewayPort = link.port
|
||||
self.manualGatewayPortText = String(link.port)
|
||||
self.manualGatewayTLS = link.tls
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
|
||||
}
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
|
||||
}
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.gatewayToken = setupAuth.token
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.gatewayPassword = setupAuth.password
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
}
|
||||
|
||||
func openGatewayQRScanner() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = "Opening QR scanner..."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.showQRScanner = false
|
||||
self.setupCode = ""
|
||||
self.applyGatewayLink(link)
|
||||
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
|
||||
Task { await self.connectAfterScannedGatewayLink() }
|
||||
}
|
||||
|
||||
func connectAfterScannedGatewayLink() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.setupStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualPortIsValid else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.gatewayToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.gatewayPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||
return false
|
||||
}
|
||||
self.setupStatusText = "Checking gateway reachability..."
|
||||
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
|
||||
if !ok {
|
||||
self.setupStatusText = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func resetOnboarding() {
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
self.gatewayAutoConnect = false
|
||||
self.suppressCredentialPersist = true
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
self.manualGatewayEnabled = false
|
||||
self.manualGatewayHost = ""
|
||||
self.onboardingRequestID += 1
|
||||
}
|
||||
|
||||
func retryGatewayConnectionFromProblem() async {
|
||||
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
|
||||
await self.connectManual()
|
||||
} else {
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
if problem.suggestsOnboardingReset { return "Reset onboarding" }
|
||||
return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
}
|
||||
|
||||
func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
|
||||
if problem.suggestsOnboardingReset {
|
||||
self.resetOnboarding()
|
||||
return
|
||||
}
|
||||
if problem.canTrustRotatedCertificate {
|
||||
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
|
||||
return
|
||||
}
|
||||
await self.retryGatewayConnectionFromProblem()
|
||||
}
|
||||
|
||||
func handleLocationModeChange(_ newValue: String) {
|
||||
guard !self.isChangingLocationMode else { return }
|
||||
guard newValue != self.previousLocationModeRaw else { return }
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
let previous = self.previousLocationModeRaw
|
||||
Task {
|
||||
await self.applyLocationMode(mode, rawValue: newValue, previous: previous)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyLocationMode(
|
||||
_ mode: OpenClawLocationMode,
|
||||
rawValue: String,
|
||||
previous: String) async
|
||||
{
|
||||
self.isChangingLocationMode = true
|
||||
self.locationStatusText = nil
|
||||
defer { self.isChangingLocationMode = false }
|
||||
|
||||
if mode == .off {
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
return
|
||||
}
|
||||
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if granted {
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
} else {
|
||||
self.locationModeRaw = previous
|
||||
self.previousLocationModeRaw = previous
|
||||
self.locationStatusText = "Location permission was not granted."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNotificationSettings() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotificationAction() {
|
||||
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
|
||||
self.openSystemSettings()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
.alert,
|
||||
.badge,
|
||||
.sound,
|
||||
])) ?? false
|
||||
await MainActor.run {
|
||||
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
|
||||
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
self.notificationStatusText = "Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .denied:
|
||||
self.notificationStatusText = "Not Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .notDetermined:
|
||||
self.notificationStatusText = "Not Set"
|
||||
self.notificationActionText = "Request Access"
|
||||
@unknown default:
|
||||
self.notificationStatusText = "Unknown"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
}
|
||||
}
|
||||
|
||||
func persistGatewayToken(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func persistGatewayPassword(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
func title(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Gateway"
|
||||
case .permissions: "Permissions"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
case .notifications: "Notifications"
|
||||
case .about: "About"
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
self.manualGatewayPortText = filtered
|
||||
self.manualGatewayPort = Int(filtered) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
func resolvedManualPort(host: String) -> Int? {
|
||||
if self.manualGatewayPort > 0 {
|
||||
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
var setupStatusLine: String? {
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.message
|
||||
}
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
||||
if !trimmedSetup.isEmpty { return trimmedSetup }
|
||||
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
||||
return gatewayStatus
|
||||
}
|
||||
|
||||
var tailnetWarningText: String? {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
|
||||
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
|
||||
}
|
||||
|
||||
func friendlyGatewayMessage(from raw: String) -> String? {
|
||||
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if lower.contains("pairing required") {
|
||||
return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again."
|
||||
}
|
||||
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||
return "Secure handshake failed. Check Tailscale, then connect again."
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
||||
}
|
||||
if lower.contains("unauthorized role") {
|
||||
return "Connected, but some controls are restricted for nodes. This is expected."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var shouldShowRealtimeVoicePicker: Bool {
|
||||
let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw)
|
||||
return providerSelection == .openAIRealtime || self.appModel.talkMode.gatewayTalkUsesRealtime
|
||||
}
|
||||
|
||||
var talkProviderSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkProviderSelectionRaw },
|
||||
set: { newValue in
|
||||
let selection = TalkModeProviderSelection.resolved(newValue)
|
||||
self.talkProviderSelectionRaw = selection.rawValue
|
||||
self.appModel.setTalkProviderSelection(selection.rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkRealtimeVoiceSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkRealtimeVoiceSelectionRaw },
|
||||
set: { newValue in
|
||||
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? ""
|
||||
self.talkRealtimeVoiceSelectionRaw = voice
|
||||
self.appModel.setTalkRealtimeVoiceSelection(voice)
|
||||
})
|
||||
}
|
||||
|
||||
var talkSpeakerphoneBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.talkSpeakerphoneEnabled },
|
||||
set: { newValue in
|
||||
self.talkSpeakerphoneEnabled = newValue
|
||||
self.appModel.setTalkSpeakerphoneEnabled(newValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkApiKeyStatus: String {
|
||||
guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" }
|
||||
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
let gw = gateway.gatewayPort.map(String.init)
|
||||
let canvas = gateway.canvasPort.map(String.init)
|
||||
if gw != nil || canvas != nil {
|
||||
lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")")
|
||||
}
|
||||
return lines.isEmpty ? [gateway.debugID] : lines
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
var gatewayAddress: String {
|
||||
self.appModel.gatewayRemoteAddress ?? "Waiting for gateway"
|
||||
}
|
||||
|
||||
var gatewayServer: String {
|
||||
self.appModel.gatewayServerName ?? "OpenClaw Gateway"
|
||||
}
|
||||
|
||||
var permissionsDetail: String {
|
||||
var enabled = 0
|
||||
if self.cameraEnabled { enabled += 1 }
|
||||
if self.locationModeRaw != OpenClawLocationMode.off.rawValue { enabled += 1 }
|
||||
if self.preventSleep { enabled += 1 }
|
||||
return "\(enabled) enabled"
|
||||
}
|
||||
|
||||
var voiceDetail: String {
|
||||
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
|
||||
if self.talkEnabled { return "Talk on" }
|
||||
if self.voiceWakeEnabled { return "Wake on" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var diagnosticsDetail: String {
|
||||
"System checks"
|
||||
}
|
||||
|
||||
var diagnosticsHealthValue: String {
|
||||
if self.gatewayConnected { return "ready" }
|
||||
if self.gatewayController.gateways.isEmpty { return "check" }
|
||||
return "partial"
|
||||
}
|
||||
|
||||
var diagnosticsRunValue: String {
|
||||
guard let diagnosticsIssueCount else { return "pending" }
|
||||
return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)"
|
||||
}
|
||||
|
||||
var diagnosticsRunColor: Color {
|
||||
guard let diagnosticsIssueCount else { return .secondary }
|
||||
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
var privacyDetail: String {
|
||||
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
return location == .off ? "Location off" : "Location \(self.locationLabel)"
|
||||
}
|
||||
|
||||
var locationLabel: String {
|
||||
switch OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off {
|
||||
case .off: "Off"
|
||||
case .whileUsing: "While Using"
|
||||
case .always: "Always"
|
||||
}
|
||||
}
|
||||
}
|
||||
807
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
807
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
@@ -0,0 +1,807 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsProTab {
|
||||
var settingsHeader: some View {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var appearanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Appearance", uppercase: false)
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Appearance", selection: self.$appearancePreferenceRaw) {
|
||||
ForEach(AppAppearancePreference.allCases) { preference in
|
||||
Text(preference.label).tag(preference.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Text("Follows iOS appearance.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Gateway", uppercase: false)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
self.gatewayConnectionRow
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Address", value: self.gatewayAddress)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Server", value: self.gatewayServer)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
Divider()
|
||||
self.gatewayActions
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayConnectionRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "antenna.radiowaves.left.and.right",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Connection")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayDetailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
var gatewayActions: some View {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayActionButton(
|
||||
title: "Reconnect",
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
color: OpenClawBrand.warn,
|
||||
isBusy: self.isReconnectingGateway)
|
||||
{
|
||||
Task { await self.reconnectGateway() }
|
||||
}
|
||||
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var settingsListSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
self.settingsListRow(
|
||||
icon: "person.2",
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.voiceDetail,
|
||||
route: .voice)
|
||||
self.settingsListRow(
|
||||
icon: "globe",
|
||||
title: "Diagnostics",
|
||||
detail: self.diagnosticsDetail,
|
||||
route: .diagnostics)
|
||||
self.settingsListRow(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: self.privacyDetail,
|
||||
route: .privacy)
|
||||
self.settingsListRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: self.notificationStatusText,
|
||||
route: .notifications)
|
||||
self.settingsListRow(
|
||||
icon: "info.circle",
|
||||
title: "About",
|
||||
detail: DeviceInfoHelper.openClawVersionString(),
|
||||
route: .about)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func settingsListRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
route: SettingsRoute) -> some View
|
||||
{
|
||||
NavigationLink(value: route) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
|
||||
.proPanelSurface(radius: SettingsLayout.cardRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func destination(for route: SettingsRoute) -> some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
self.diagnosticsDestination
|
||||
case .privacy:
|
||||
self.privacyDestination
|
||||
case .notifications:
|
||||
self.notificationsDestination
|
||||
case .about:
|
||||
self.aboutDestination
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
self.gatewayProblemCard(gatewayProblem)
|
||||
}
|
||||
|
||||
self.detailStatusCard(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Address", value: self.gatewayAddress)
|
||||
Divider()
|
||||
self.detailRow("Server", value: self.gatewayServer)
|
||||
Divider()
|
||||
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
|
||||
Divider()
|
||||
self.detailRow("Active Agent", value: self.appModel.activeAgentName)
|
||||
Divider()
|
||||
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
}
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActions
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.deviceIdentityCard
|
||||
self.agentSelectionCard
|
||||
self.gatewaySetupCard
|
||||
self.discoveredGatewaysCard
|
||||
self.manualGatewayCard
|
||||
self.gatewayAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var permissionsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.toggleCard(
|
||||
icon: "camera",
|
||||
title: "Camera",
|
||||
detail: "Allow the gateway to request photos or video while OpenClaw is foregrounded.",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
icon: "lock.display",
|
||||
title: "Keep Awake",
|
||||
detail: "Keep the screen awake while OpenClaw is open.",
|
||||
isOn: self.$preventSleep)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var voiceDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||||
value: self.voiceDetail,
|
||||
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
|
||||
|
||||
self.voiceFeatureCard
|
||||
self.talkVoiceSettingsCard
|
||||
self.shareSettingsCard
|
||||
}
|
||||
}
|
||||
|
||||
var diagnosticsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "checklist.checked",
|
||||
title: "Health Check",
|
||||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||||
value: self.diagnosticsHealthValue,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.diagnosticsAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var privacyDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: "Control what device context OpenClaw can expose to the gateway.",
|
||||
value: self.privacyDetail,
|
||||
color: .secondary)
|
||||
|
||||
self.toggleCard(
|
||||
icon: "camera",
|
||||
title: "Camera Access",
|
||||
detail: "Disable to block camera capture requests from the gateway.",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
icon: "lock.open.display",
|
||||
title: "Background Listening",
|
||||
detail: "Allow active Talk sessions to continue while the app is backgrounded.",
|
||||
isOn: self.$talkBackgroundEnabled)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var notificationsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approvals and event alerts from OpenClaw.",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
self.handleNotificationAction()
|
||||
} label: {
|
||||
Label(
|
||||
self.notificationActionText,
|
||||
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var aboutDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "info.circle",
|
||||
title: "OpenClaw",
|
||||
detail: "iOS companion app",
|
||||
value: DeviceInfoHelper.openClawVersionString(),
|
||||
color: OpenClawBrand.accent)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Version", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayActionButton(
|
||||
title: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
isBusy: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: isBusy ? "hourglass" : icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.76)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
.foregroundStyle(color)
|
||||
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(color.opacity(0.14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
|
||||
func toggleCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
isOn: Binding<Bool>) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
Toggle(isOn: isOn) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: isOn.wrappedValue ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var locationModeCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "location",
|
||||
color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand
|
||||
.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Location")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Controls whether location can be shared with gateway tools.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if self.isChangingLocationMode {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Location", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(self.isChangingLocationMode)
|
||||
|
||||
if let locationStatusText {
|
||||
Text(locationStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var agentSelectionCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Active Agent")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Agent", selection: self.$selectedAgentPickerId) {
|
||||
Text("Default").tag("")
|
||||
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||||
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(name.isEmpty ? agent.id : name).tag(agent.id)
|
||||
}
|
||||
}
|
||||
Text("Controls which agent Chat and Talk use.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var gatewaySetupCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Setup Code")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayActionButton(
|
||||
title: "Scan QR",
|
||||
icon: "qrcode.viewfinder",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID != nil)
|
||||
{
|
||||
self.openGatewayQRScanner()
|
||||
}
|
||||
self.gatewayActionButton(
|
||||
title: "Connect",
|
||||
icon: "bolt.horizontal.circle",
|
||||
color: OpenClawBrand.ok,
|
||||
isBusy: self.connectingGatewayID == "manual")
|
||||
{
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
if let status = self.setupStatusLine {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let warning = self.tailnetWarningText {
|
||||
Text(warning)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var discoveredGatewaysCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Discovered Gateways")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
self.discoveredGatewayRow(gateway)
|
||||
if gateway.id != self.gatewayController.gateways.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(verbatim: gateway.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
|
||||
var manualGatewayCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Port", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
self.gatewayActionButton(
|
||||
title: "Connect Manual",
|
||||
icon: "network",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID == "manual")
|
||||
{
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
.disabled(self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !self.manualPortIsValid)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var gatewayAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Reset Onboarding", role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var voiceFeatureCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
|
||||
self.appModel.setVoiceWakeEnabled(enabled)
|
||||
}
|
||||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||||
self.appModel.setTalkEnabled(enabled)
|
||||
}
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
}
|
||||
}
|
||||
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(
|
||||
title: "Wake Words",
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var talkVoiceSettingsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var shareSettingsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Show Talk Control", isOn: self.$talkButtonEnabled)
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||||
} label: {
|
||||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Text(self.appModel.lastShareEventText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var privacyAccessCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
PrivacyAccessSectionView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var diagnosticsAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||||
}
|
||||
Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
NavigationLink {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(title: "Discovery Logs", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var deviceIdentityCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Device Name", text: self.$displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
self.detailRow("Instance ID", value: self.instanceId)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func gatewayProblemCard(_ problem: GatewayConnectionProblem) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
GatewayProblemBanner(
|
||||
problem: problem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(problem) }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func settingsToggle(
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
Toggle(title, isOn: isOn)
|
||||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||||
onChange?(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleSettingsRow(title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
105
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
105
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Darwin
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case permissions
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
case notifications
|
||||
case about
|
||||
}
|
||||
|
||||
enum SettingsLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let rowHeight: CGFloat = 58
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
case gatewayOffline
|
||||
case discoveryUnavailable
|
||||
case talkConfigMissing
|
||||
case notificationsUnavailable
|
||||
}
|
||||
|
||||
enum SettingsDiagnostics {
|
||||
static func issues(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> [SettingsDiagnosticIssue]
|
||||
{
|
||||
var issues: [SettingsDiagnosticIssue] = []
|
||||
if !gatewayConnected { issues.append(.gatewayOffline) }
|
||||
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
|
||||
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
|
||||
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
|
||||
return issues
|
||||
}
|
||||
|
||||
static func issueCount(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> Int
|
||||
{
|
||||
self.issues(
|
||||
gatewayConnected: gatewayConnected,
|
||||
discoveredGatewayCount: discoveredGatewayCount,
|
||||
talkConfigLoaded: talkConfigLoaded,
|
||||
notificationStatusText: notificationStatusText).count
|
||||
}
|
||||
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsProTab {
|
||||
static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
defer { freeifaddrs(addrList) }
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
guard let addrPtr = ptr.pointee.ifa_addr else { continue }
|
||||
let family = addrPtr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
var addr = addrPtr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(addrPtr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if self.isTailnetIPv4(ip) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isTailnetHostOrIP(_ host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { return true }
|
||||
return self.isTailnetIPv4(trimmed)
|
||||
}
|
||||
|
||||
static func isTailnetIPv4(_ ip: String) -> Bool {
|
||||
let parts = ip.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
guard (0...255).contains(a), (0...255).contains(b) else { return false }
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
652
apps/ios/Sources/Design/TalkProTab.swift
Normal file
652
apps/ios/Sources/Design/TalkProTab.swift
Normal file
@@ -0,0 +1,652 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TalkProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
var openSettings: () -> Void
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
self.controlsCard
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.sheet(isPresented: self.$showPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showPermissionPrompt = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not Now") {
|
||||
self.showPermissionPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerSubtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.statusChip
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var statusChip: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(self.state.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.state.chipText)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(self.state.color.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceHeroCard: some View {
|
||||
CommandPanel(tint: self.state.color, isProminent: true, padding: 16) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
TalkProOrb(
|
||||
mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel),
|
||||
color: self.state.color,
|
||||
systemImage: self.state.icon)
|
||||
.frame(height: 188)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 5) {
|
||||
Text(self.state.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(self.heroSubtitle)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: self.handlePrimaryAction) {
|
||||
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(self.state.primaryButtonFill)
|
||||
.shadow(color: self.state.color.opacity(0.28), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.state.primaryAction == .waiting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var conversationCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Conversation", value: self.state.chipText, color: self.state.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Session",
|
||||
value: self.appModel.chatSessionKey)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: self.state.icon, title: "Runtime", value: self.appModel.talkMode.statusText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var voiceModeCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Voice mode",
|
||||
value: "Settings ›",
|
||||
color: OpenClawBrand.accent,
|
||||
action: self.openSettings)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "globe", title: "Speech language", value: self.speechLocaleText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var controlsCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Controls", value: nil, color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Toggle("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
Button(action: self.openSettings) {
|
||||
HStack {
|
||||
Label("Voice & Talk settings", systemImage: "slider.horizontal.3")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "—" : value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var headerSubtitle: String {
|
||||
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent }
|
||||
if agent.isEmpty { return mode }
|
||||
return "\(agent) • \(mode)"
|
||||
}
|
||||
|
||||
private var heroSubtitle: String {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
return "Routes voice to \(self.appModel.activeAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
|
||||
if transport.isEmpty || transport == "Not loaded" { return provider }
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
}
|
||||
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
|
||||
}
|
||||
|
||||
private var speechLocaleText: String {
|
||||
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
|
||||
return self.talkSpeechLocale
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
{
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch self.state.primaryAction {
|
||||
case .start:
|
||||
self.startTalk()
|
||||
case .stop:
|
||||
self.stopTalk()
|
||||
case .enablePermission:
|
||||
self.stopTalk()
|
||||
self.showPermissionPrompt = true
|
||||
case .openSettings:
|
||||
self.openSettings()
|
||||
case .waiting:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
enum TalkProPrimaryAction: Equatable {
|
||||
case start
|
||||
case stop
|
||||
case enablePermission
|
||||
case openSettings
|
||||
case waiting
|
||||
}
|
||||
|
||||
enum TalkProWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
if !self.gatewayConnected { return "Gateway offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
case .apiKeyMissing:
|
||||
return "Voice API key missing"
|
||||
case .loadFailed:
|
||||
return "Voice config failed"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.isEnabled { return "Ready to talk" }
|
||||
return "Talk is off"
|
||||
}
|
||||
|
||||
var chipText: String {
|
||||
if !self.gatewayConnected { return "Offline" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Needs approval"
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return "Pending"
|
||||
case .apiKeyMissing:
|
||||
return "API key"
|
||||
case .loadFailed:
|
||||
return "Config"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.isEnabled { return "Ready" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
if !self.gatewayConnected { return "wifi.slash" }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "key.fill"
|
||||
case .requestingUpgrade:
|
||||
return "paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
return "hourglass"
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if !self.gatewayConnected { return .secondary }
|
||||
switch self.permissionState {
|
||||
case .requestFailed, .loadFailed:
|
||||
return OpenClawBrand.danger
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
|
||||
var primaryAction: TalkProPrimaryAction {
|
||||
if !self.gatewayConnected { return .openSettings }
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return .enablePermission
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .waiting
|
||||
case .apiKeyMissing, .loadFailed:
|
||||
return .openSettings
|
||||
default:
|
||||
return self.isEnabled ? .stop : .start
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonTitle: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "Start Talk"
|
||||
case .stop: "Stop Talk"
|
||||
case .enablePermission: "Enable Talk"
|
||||
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
|
||||
case .waiting: "Waiting for Approval"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonIcon: String {
|
||||
switch self.primaryAction {
|
||||
case .start: "play.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .enablePermission: "key.fill"
|
||||
case .openSettings: "gearshape.fill"
|
||||
case .waiting: "hourglass"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtonFill: AnyShapeStyle {
|
||||
switch self.primaryAction {
|
||||
case .stop:
|
||||
AnyShapeStyle(OpenClawBrand.danger)
|
||||
case .waiting:
|
||||
AnyShapeStyle(OpenClawBrand.warn.opacity(0.72))
|
||||
default:
|
||||
AnyShapeStyle(LinearGradient(
|
||||
colors: [self.color.opacity(0.95), OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
|
||||
if !self.gatewayConnected { return .still }
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed, .apiKeyMissing, .loadFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return self.isEnabled ? .indeterminate : .still
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProOrb: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let color: Color
|
||||
let systemImage: String
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { ring in
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(self.ringOpacity(ring)), lineWidth: 1.4)
|
||||
.scaleEffect(self.ringScale(ring, date: timeline.date))
|
||||
}
|
||||
Circle()
|
||||
.fill(self.color.opacity(0.13))
|
||||
.frame(width: 128, height: 128)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(0.30), lineWidth: 1)
|
||||
}
|
||||
TalkProWaveform(mode: self.mode, tint: self.color, barCount: 18)
|
||||
.frame(width: 116, height: 52)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 1 : 0.34)
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(self.color)
|
||||
.opacity(self.systemImage == "waveform" || self.systemImage == "mic.fill" ? 0.20 : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func ringScale(_ ring: Int, date: Date) -> CGFloat {
|
||||
guard !self.reduceMotion else { return CGFloat(1.0 + (Double(ring) * 0.12)) }
|
||||
let base = 0.88 + (Double(ring) * 0.18)
|
||||
let speed = self.mode == .still ? 0.8 : 1.8
|
||||
let phase = date.timeIntervalSinceReferenceDate * speed + Double(ring) * 0.9
|
||||
return CGFloat(base + (sin(phase) * 0.035))
|
||||
}
|
||||
|
||||
private func ringOpacity(_ ring: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
0.10 - (Double(ring) * 0.018)
|
||||
default:
|
||||
0.24 - (Double(ring) * 0.045)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkProWaveform: View {
|
||||
let mode: TalkProWaveformMode
|
||||
let tint: Color
|
||||
let barCount: Int
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
ForEach(0..<self.barCount, id: \.self) { index in
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.tint.opacity(self.opacity(for: index)))
|
||||
.frame(width: 4, height: self.height(for: index, date: timeline.date))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, date: Date) -> CGFloat {
|
||||
let minimum = 6.0
|
||||
let maximum = 48.0
|
||||
return CGFloat(minimum + ((maximum - minimum) * self.amplitude(for: index, date: date)))
|
||||
}
|
||||
|
||||
private func opacity(for index: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
index == self.barCount / 2 ? 0.64 : 0.30
|
||||
default:
|
||||
0.82
|
||||
}
|
||||
}
|
||||
|
||||
private func amplitude(for index: Int, date: Date) -> Double {
|
||||
if self.reduceMotion {
|
||||
switch self.mode {
|
||||
case let .level(level): return min(max(level, 0.10), 1.0)
|
||||
case .inputSpeech: return 0.72
|
||||
case .speaking: return 0.62
|
||||
case .indeterminate: return 0.34
|
||||
case .still: return 0.18
|
||||
}
|
||||
}
|
||||
|
||||
let t = date.timeIntervalSinceReferenceDate
|
||||
let phase = Double(index) * 0.52
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
let clamped = min(max(level, 0), 1)
|
||||
let shaped = 0.12 + (0.88 * clamped)
|
||||
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
|
||||
return min(max(shaped * variation, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
|
||||
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
|
||||
case .speaking:
|
||||
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
|
||||
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
|
||||
case .indeterminate:
|
||||
let center = (sin((t * 3.2) + phase) + 1) / 2
|
||||
return 0.16 + (0.42 * center)
|
||||
case .still:
|
||||
return index == self.barCount / 2 ? 0.32 : 0.16
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
@@ -16,7 +15,6 @@ private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
prompt: prompt,
|
||||
isResolving: self.appModel.pendingExecApprovalPromptResolving,
|
||||
errorText: self.appModel.pendingExecApprovalPromptErrorText,
|
||||
brighten: self.colorScheme == .light,
|
||||
onAllowOnce: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
|
||||
@@ -50,7 +48,6 @@ private struct ExecApprovalPromptCard: View {
|
||||
let prompt: NodeAppModel.ExecApprovalPrompt
|
||||
let isResolving: Bool
|
||||
let errorText: String?
|
||||
let brighten: Bool
|
||||
let onAllowOnce: () -> Void
|
||||
let onAllowAlways: () -> Void
|
||||
let onDeny: () -> Void
|
||||
@@ -147,7 +144,8 @@ private struct ExecApprovalPromptCard: View {
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
||||
.padding(18)
|
||||
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
|
||||
@@ -25,4 +25,53 @@ struct GatewayConnectConfig {
|
||||
if trimmed.isEmpty { return self.url.absoluteString }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func hasSameConnectionInputs(as other: GatewayConnectConfig) -> Bool {
|
||||
self.url == other.url &&
|
||||
self.stableID == other.stableID &&
|
||||
Self.sameTLS(self.tls, other.tls) &&
|
||||
self.token == other.token &&
|
||||
self.bootstrapToken == other.bootstrapToken &&
|
||||
self.password == other.password &&
|
||||
Self.sameOptions(self.nodeOptions, other.nodeOptions)
|
||||
}
|
||||
|
||||
private static func sameTLS(_ lhs: GatewayTLSParams?, _ rhs: GatewayTLSParams?) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (nil, nil):
|
||||
true
|
||||
case let (lhs?, rhs?):
|
||||
lhs.required == rhs.required &&
|
||||
lhs.expectedFingerprint == rhs.expectedFingerprint &&
|
||||
lhs.allowTOFU == rhs.allowTOFU &&
|
||||
lhs.storeKey == rhs.storeKey
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func sameOptions(_ lhs: GatewayConnectOptions, _ rhs: GatewayConnectOptions) -> Bool {
|
||||
let lhsScopes = Self.normalizedValues(lhs.scopes)
|
||||
let rhsScopes = Self.normalizedValues(rhs.scopes)
|
||||
let lhsCaps = Self.normalizedValues(lhs.caps)
|
||||
let rhsCaps = Self.normalizedValues(rhs.caps)
|
||||
let lhsCommands = Self.normalizedValues(lhs.commands)
|
||||
let rhsCommands = Self.normalizedValues(rhs.commands)
|
||||
return lhs.role == rhs.role &&
|
||||
lhs.scopesAreExplicit == rhs.scopesAreExplicit &&
|
||||
lhs.clientId == rhs.clientId &&
|
||||
lhs.clientMode == rhs.clientMode &&
|
||||
lhs.clientDisplayName == rhs.clientDisplayName &&
|
||||
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
|
||||
lhsScopes == rhsScopes &&
|
||||
lhsCaps == rhsCaps &&
|
||||
lhsCommands == rhsCommands &&
|
||||
lhs.permissions == rhs.permissions
|
||||
}
|
||||
|
||||
private static func normalizedValues(_ values: [String]) -> [String] {
|
||||
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,12 @@ final class GatewayConnectionController {
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: PendingTrustConnect?
|
||||
|
||||
private struct SavedManualEndpoint: Equatable {
|
||||
let host: String
|
||||
let port: Int
|
||||
let useTLS: Bool
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
|
||||
@@ -181,7 +187,8 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
forceReconnect: Bool = false) async -> String?
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -245,7 +252,8 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,7 +265,8 @@ final class GatewayConnectionController {
|
||||
host: String,
|
||||
port: Int,
|
||||
useTLS: Bool,
|
||||
authOverride: ManualAuthOverride? = nil) async
|
||||
authOverride: ManualAuthOverride? = nil,
|
||||
forceReconnect: Bool = false) async
|
||||
{
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let token =
|
||||
@@ -319,27 +328,38 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS, forceReconnect: true)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
|
||||
_ = await self.connectSavedManualEndpointFallback()
|
||||
return
|
||||
}
|
||||
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild connect options from current local settings (caps/commands/permissions)
|
||||
/// and re-apply the active gateway config so capability changes take effect immediately.
|
||||
func refreshActiveGatewayRegistrationFromSettings() {
|
||||
Task { [weak self] in
|
||||
await self?.refreshActiveGatewayRegistrationFromSettingsAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshActiveGatewayRegistrationFromSettingsAsync() async {
|
||||
guard let appModel else { return }
|
||||
guard let cfg = appModel.activeGatewayConnectConfig else { return }
|
||||
guard appModel.gatewayAutoReconnectEnabled else { return }
|
||||
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: cfg.stableID)
|
||||
let refreshedConfig = GatewayConnectConfig(
|
||||
url: cfg.url,
|
||||
stableID: cfg.stableID,
|
||||
@@ -347,7 +367,7 @@ final class GatewayConnectionController {
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
||||
nodeOptions: nodeOptions)
|
||||
appModel.applyGatewayConnectConfig(refreshedConfig)
|
||||
}
|
||||
|
||||
@@ -522,32 +542,14 @@ final class GatewayConnectionController {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
if case let .manual(host, port, useTLS, stableID) = lastKnown {
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return }
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
||||
self.startLastKnownAutoConnect(
|
||||
lastKnown,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -582,6 +584,44 @@ final class GatewayConnectionController {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.startSavedManualEndpointFallback()
|
||||
}
|
||||
|
||||
private func startLastKnownAutoConnect(
|
||||
_ lastKnown: GatewaySettingsStore.LastGatewayConnection,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?) -> Bool
|
||||
{
|
||||
switch lastKnown {
|
||||
case let .manual(host, port, useTLS, stableID):
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return false }
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return false }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
return true
|
||||
case .discovered:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptAutoReconnectIfNeeded() {
|
||||
@@ -594,6 +634,46 @@ final class GatewayConnectionController {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
private func savedManualEndpointFallback(defaults: UserDefaults = .standard) -> SavedManualEndpoint? {
|
||||
guard defaults.bool(forKey: "gateway.autoconnect") else { return nil }
|
||||
guard defaults.bool(forKey: "gateway.manual.enabled") else { return nil }
|
||||
let host = defaults.string(forKey: "gateway.manual.host")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
|
||||
let configuredPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let configuredUseTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: configuredUseTLS)
|
||||
guard let resolvedPort = self.resolveManualPort(
|
||||
host: host,
|
||||
port: configuredPort,
|
||||
useTLS: resolvedUseTLS)
|
||||
else { return nil }
|
||||
|
||||
return SavedManualEndpoint(host: host, port: resolvedPort, useTLS: resolvedUseTLS)
|
||||
}
|
||||
|
||||
private func startSavedManualEndpointFallback() -> Bool {
|
||||
guard let endpoint = self.savedManualEndpointFallback() else { return false }
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
await self?.connectManual(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
useTLS: endpoint.useTLS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func connectSavedManualEndpointFallback() async -> Bool {
|
||||
guard let endpoint = self.savedManualEndpointFallback() else { return false }
|
||||
await self.connectManual(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
useTLS: endpoint.useTLS)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
let defaults = UserDefaults.standard
|
||||
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -615,16 +695,14 @@ final class GatewayConnectionController {
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?)
|
||||
password: String?,
|
||||
forceReconnect: Bool = false)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||
|
||||
Task { [weak appModel] in
|
||||
guard let appModel else { return }
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
Task { [weak self, weak appModel] in
|
||||
guard let self, let appModel else { return }
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID)
|
||||
let cfg = GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: gatewayStableID,
|
||||
@@ -632,8 +710,8 @@ final class GatewayConnectionController {
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: connectOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg)
|
||||
nodeOptions: nodeOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg, forceReconnect: forceReconnect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +902,9 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
@@ -852,17 +932,18 @@ final class GatewayConnectionController {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||
private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||
let permissions = await self.currentPermissions()
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: self.currentPermissions(),
|
||||
permissions: permissions,
|
||||
clientId: resolvedClientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
@@ -1000,14 +1081,16 @@ final class GatewayConnectionController {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentPermissions() -> [String: Bool] {
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
var permissions: [String: Bool] = [:]
|
||||
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
permissions["location"] = Self.isLocationAuthorized(
|
||||
status: CLLocationManager().authorizationStatus)
|
||||
&& CLLocationManager.locationServicesEnabled()
|
||||
let locationStatus = CLLocationManager().authorizationStatus
|
||||
let locationServicesEnabled = await Self.locationServicesEnabled()
|
||||
permissions["location"] = Self.isLocationAvailable(
|
||||
servicesEnabled: locationServicesEnabled,
|
||||
status: locationStatus)
|
||||
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||
|
||||
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
@@ -1034,12 +1117,19 @@ final class GatewayConnectionController {
|
||||
return permissions
|
||||
}
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
private static func locationServicesEnabled() async -> Bool {
|
||||
await Task.detached(priority: .utility) {
|
||||
CLLocationManager.locationServicesEnabled()
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
guard servicesEnabled else { return false }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
true
|
||||
return true
|
||||
default:
|
||||
false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,8 +1156,12 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() -> [String: Bool] {
|
||||
self.currentPermissions()
|
||||
func _test_currentPermissions() async -> [String: Bool] {
|
||||
await self.currentPermissions()
|
||||
}
|
||||
|
||||
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
@@ -1112,6 +1206,14 @@ extension GatewayConnectionController {
|
||||
func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||
self.resolveManualPort(host: host, port: port, useTLS: useTLS)
|
||||
}
|
||||
|
||||
func _test_savedManualEndpointFallback(
|
||||
defaults: UserDefaults = .standard) -> (host: String, port: Int, useTLS: Bool)?
|
||||
{
|
||||
self.savedManualEndpointFallback(defaults: defaults).map { endpoint in
|
||||
(host: endpoint.host, port: endpoint.port, useTLS: endpoint.useTLS)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user